Refactor JSON/SignalR infra; add full test & benchmark suite
Major overhaul of JSON serialization/deserialization: - Added AcJsonDeserializationException for clearer error reporting. - Robust JSON validation, type checking, and double-serialization detection. - Fast-path primitive (de)serialization for all common .NET types. - Direct support for Dictionary<TKey, TValue> and improved collection handling. - Enhanced $id/$ref reference resolution and in-place merging. - Optimized property metadata caching and filtering. - Comprehensive error handling with detailed messages. - Extensive new tests for primitives, collections, references, and error cases. - Benchmarks for serialization, deserialization, and merge scenarios. SignalR infrastructure improvements: - Refactored AcSignalRClientBase/AcWebSignalRHubBase for testability (virtual methods, test constructors). - Added SignalRRequestModelPool for efficient request/response tracking. - Improved parameter deserialization and IdMessage handling. - New tags (PingTag, EchoTag) for SignalR messaging. New test and benchmark infrastructure for SignalR: - Shared test models and data factories for DTOs, primitives, and complex graphs. - In-memory, dependency-free SignalR client/hub for round-trip testing. - Exhaustive test suites for message processing, method invocation, and edge cases (including prior production bugs with Task<T>). - Benchmarks for SignalR serialization, deserialization, and round-trip performance. Other changes: - Improved TaskHelper.WaitTo/WaitToAsync with more accurate polling and cancellation support. - ExtensionMethods.InvokeMethod now properly unwraps Task/ValueTask results. - General code cleanup, improved comments, and removal of obsolete code. - Project references updated for shared test/benchmark infrastructure. These changes deliver a robust, high-performance, and fully tested JSON/SignalR (de)serialization system, ready for production and advanced testing scenarios.
This commit is contained in:
parent
5abff05031
commit
c29b3daa0e
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,342 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Interfaces;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AyCode.Core.Tests.TestModels;
|
||||
|
||||
#region Shared Enums
|
||||
|
||||
/// <summary>
|
||||
/// Common status enum for all test entities
|
||||
/// </summary>
|
||||
public enum TestStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Active = 1,
|
||||
Processing = 2,
|
||||
Completed = 3,
|
||||
Shipped = 4,
|
||||
OnHold = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Priority levels for tasks and projects
|
||||
/// </summary>
|
||||
public enum TestPriority
|
||||
{
|
||||
Low = 0,
|
||||
Medium = 1,
|
||||
High = 2,
|
||||
Critical = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User roles for testing
|
||||
/// </summary>
|
||||
public enum TestUserRole
|
||||
{
|
||||
User = 0,
|
||||
Manager = 1,
|
||||
Admin = 2
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shared Reference Types (IId-based for $id/$ref testing)
|
||||
|
||||
/// <summary>
|
||||
/// Shared tag/label - used across multiple entities for cross-reference testing.
|
||||
/// Implements IId<int> for semantic $id/$ref serialization.
|
||||
/// </summary>
|
||||
public class SharedTag : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Color { get; set; } = "#000000";
|
||||
public int Priority { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared category - for hierarchical cross-reference testing.
|
||||
/// </summary>
|
||||
public class SharedCategory : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
public int? ParentCategoryId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared user reference - appears in many places to test $ref deduplication.
|
||||
/// </summary>
|
||||
public class SharedUser : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Username { get; set; } = "";
|
||||
public string Email { get; set; } = "";
|
||||
public string FirstName { get; set; } = "";
|
||||
public string LastName { get; set; } = "";
|
||||
public bool IsActive { get; set; } = true;
|
||||
public TestUserRole Role { get; set; } = TestUserRole.User;
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public UserPreferences? Preferences { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User preferences - non-IId nested object
|
||||
/// </summary>
|
||||
public class UserPreferences
|
||||
{
|
||||
public string Theme { get; set; } = "light";
|
||||
public string Language { get; set; } = "en-US";
|
||||
public bool NotificationsEnabled { get; set; } = true;
|
||||
public string? EmailDigestFrequency { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Non-IId Metadata (Newtonsoft numeric $id/$ref testing)
|
||||
|
||||
/// <summary>
|
||||
/// Non-IId metadata class - uses Newtonsoft PreserveReferencesHandling (numeric $id/$ref).
|
||||
/// Does NOT implement IId, so uses standard Newtonsoft reference tracking.
|
||||
/// </summary>
|
||||
public class MetadataInfo
|
||||
{
|
||||
public string Key { get; set; } = "";
|
||||
public string Value { get; set; } = "";
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Nested metadata for deep Newtonsoft reference testing
|
||||
/// </summary>
|
||||
public MetadataInfo? ChildMetadata { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 5-Level Test Hierarchy (Order -> Item -> Pallet -> Measurement -> Point)
|
||||
|
||||
/// <summary>
|
||||
/// Level 1: Main order - root of the hierarchy
|
||||
/// </summary>
|
||||
public class TestOrder : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OrderNumber { get; set; } = "";
|
||||
public TestStatus Status { get; set; } = TestStatus.Pending;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? PaidDateUtc { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
// Level 2 collection
|
||||
public List<TestOrderItem> Items { get; set; } = [];
|
||||
|
||||
// Shared reference properties (for $id/$ref testing)
|
||||
public SharedTag? PrimaryTag { get; set; }
|
||||
public SharedTag? SecondaryTag { get; set; }
|
||||
public SharedUser? Owner { get; set; }
|
||||
public SharedCategory? Category { get; set; }
|
||||
|
||||
// Collection of shared references
|
||||
public List<SharedTag> Tags { get; set; } = [];
|
||||
|
||||
// Non-IId metadata (for Newtonsoft $ref testing)
|
||||
public MetadataInfo? OrderMetadata { get; set; }
|
||||
public MetadataInfo? AuditMetadata { get; set; }
|
||||
public List<MetadataInfo> MetadataList { get; set; } = [];
|
||||
|
||||
// NoMerge collection for testing replace behavior
|
||||
[JsonNoMergeCollection]
|
||||
public List<TestOrderItem> NoMergeItems { get; set; } = [];
|
||||
|
||||
// Parent reference (JsonIgnore to prevent loops)
|
||||
[JsonIgnore]
|
||||
public object? Parent { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 2: Order item with pallets
|
||||
/// </summary>
|
||||
public class TestOrderItem : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string ProductName { get; set; } = "";
|
||||
public int Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public TestStatus Status { get; set; } = TestStatus.Pending;
|
||||
|
||||
// Level 3 collection
|
||||
public List<TestPallet> Pallets { get; set; } = [];
|
||||
|
||||
// Shared references
|
||||
public SharedTag? Tag { get; set; }
|
||||
public SharedUser? Assignee { get; set; }
|
||||
public MetadataInfo? ItemMetadata { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public TestOrder? ParentOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 3: Pallet containing measurements
|
||||
/// </summary>
|
||||
public class TestPallet : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string PalletCode { get; set; } = "";
|
||||
public int TrayCount { get; set; }
|
||||
public TestStatus Status { get; set; } = TestStatus.Pending;
|
||||
public double Weight { get; set; }
|
||||
|
||||
// Level 4 collection
|
||||
public List<TestMeasurement> Measurements { get; set; } = [];
|
||||
|
||||
// Shared references
|
||||
public MetadataInfo? PalletMetadata { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public TestOrderItem? ParentItem { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 4: Measurement with multiple points
|
||||
/// </summary>
|
||||
public class TestMeasurement : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public double TotalWeight { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Level 5 collection
|
||||
public List<TestMeasurementPoint> Points { get; set; } = [];
|
||||
|
||||
[JsonIgnore]
|
||||
public TestPallet? ParentPallet { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 5: Deepest level - measurement point
|
||||
/// </summary>
|
||||
public class TestMeasurementPoint : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Label { get; set; } = "";
|
||||
public double Value { get; set; }
|
||||
public DateTime MeasuredAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[JsonIgnore]
|
||||
public TestMeasurement? ParentMeasurement { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Guid-based IId types
|
||||
|
||||
/// <summary>
|
||||
/// Order with Guid Id - for testing Guid-based IId
|
||||
/// </summary>
|
||||
public class TestGuidOrder : IId<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Code { get; set; } = "";
|
||||
public List<TestGuidItem> Items { get; set; } = [];
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Item with Guid Id
|
||||
/// </summary>
|
||||
public class TestGuidItem : IId<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public int Qty { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test-specific classes
|
||||
|
||||
/// <summary>
|
||||
/// Order with nullable collections for null vs empty testing
|
||||
/// </summary>
|
||||
public class TestOrderWithNullableCollections
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OrderNumber { get; set; } = "";
|
||||
public List<TestOrderItem>? Items { get; set; }
|
||||
public List<string>? Tags { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Class with all primitive types for WASM/serialization testing
|
||||
/// </summary>
|
||||
public class PrimitiveTestClass
|
||||
{
|
||||
public int IntValue { get; set; }
|
||||
public long LongValue { get; set; }
|
||||
public double DoubleValue { get; set; }
|
||||
public decimal DecimalValue { get; set; }
|
||||
public float FloatValue { get; set; }
|
||||
public bool BoolValue { get; set; }
|
||||
public string StringValue { get; set; } = "";
|
||||
public Guid GuidValue { get; set; }
|
||||
public DateTime DateTimeValue { get; set; }
|
||||
public TestStatus EnumValue { get; set; }
|
||||
public byte ByteValue { get; set; }
|
||||
public short ShortValue { get; set; }
|
||||
public int? NullableInt { get; set; }
|
||||
public int? NullableIntNull { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Class with extended primitive types for full serializer coverage.
|
||||
/// Includes DateTimeOffset, TimeSpan, Dictionary, null properties.
|
||||
/// </summary>
|
||||
public class ExtendedPrimitiveTestClass
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
// Extended primitive types not covered in PrimitiveTestClass
|
||||
public DateTimeOffset DateTimeOffsetValue { get; set; }
|
||||
public TimeSpan TimeSpanValue { get; set; }
|
||||
public uint UIntValue { get; set; }
|
||||
public ulong ULongValue { get; set; }
|
||||
public ushort UShortValue { get; set; }
|
||||
public sbyte SByteValue { get; set; }
|
||||
public char CharValue { get; set; }
|
||||
|
||||
// Dictionary property for WriteDictionary coverage in object context
|
||||
public Dictionary<string, int>? Counts { get; set; }
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
|
||||
// Nullable properties that will be null
|
||||
public string? NullString { get; set; }
|
||||
public TestOrderItem? NullObject { get; set; }
|
||||
|
||||
// Nested object for complex serialization
|
||||
public SharedTag? Tag { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Class with array of objects containing null items for WriteNull coverage
|
||||
/// </summary>
|
||||
public class ObjectWithNullItems
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public List<object?> MixedItems { get; set; } = [];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using MessagePack;
|
||||
using MessagePack.Resolvers;
|
||||
|
||||
namespace AyCode.Core.Tests.TestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Common SignalR test/benchmark infrastructure.
|
||||
/// Provides message creation and serialization helpers used by both tests and benchmarks.
|
||||
/// </summary>
|
||||
public static class SignalRMessageFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Cached MessagePack options for ContractlessStandardResolver
|
||||
/// </summary>
|
||||
public static readonly MessagePackSerializerOptions ContractlessOptions = ContractlessStandardResolver.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Cached MessagePack options for Standard resolver
|
||||
/// </summary>
|
||||
public static readonly MessagePackSerializerOptions StandardOptions = MessagePackSerializerOptions.Standard;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MessagePack message for multiple parameters using IdMessage format.
|
||||
/// Each parameter is serialized directly as JSON.
|
||||
/// </summary>
|
||||
public static byte[] CreateIdMessage(params object[] values)
|
||||
{
|
||||
var idMessage = new SignalRIdMessageDto(values);
|
||||
var postMessage = new SignalRPostMessageDto { PostDataJson = idMessage.ToJson() };
|
||||
return MessagePackSerializer.Serialize(postMessage, ContractlessOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MessagePack message for a single primitive parameter.
|
||||
/// </summary>
|
||||
public static byte[] CreateSingleParamMessage<T>(T value) where T : notnull
|
||||
{
|
||||
return CreateIdMessage(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MessagePack message for a complex object parameter.
|
||||
/// Uses PostDataJson pattern for single complex objects.
|
||||
/// </summary>
|
||||
public static byte[] CreateComplexObjectMessage<T>(T obj)
|
||||
{
|
||||
var json = obj.ToJson();
|
||||
var postMessage = new SignalRPostMessageDto { PostDataJson = json };
|
||||
return MessagePackSerializer.Serialize(postMessage, StandardOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty MessagePack message for parameterless methods.
|
||||
/// </summary>
|
||||
public static byte[] CreateEmptyMessage()
|
||||
{
|
||||
var postMessage = new SignalRPostMessageDto();
|
||||
return MessagePackSerializer.Serialize(postMessage, ContractlessOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a response message in MessagePack format.
|
||||
/// </summary>
|
||||
public static byte[] CreateResponseMessage(int messageTag, byte status, string? responseDataJson)
|
||||
{
|
||||
var response = new SignalRResponseDto
|
||||
{
|
||||
MessageTag = messageTag,
|
||||
Status = status,
|
||||
ResponseData = responseDataJson
|
||||
};
|
||||
return MessagePackSerializer.Serialize(response, ContractlessOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a success response message in MessagePack format.
|
||||
/// </summary>
|
||||
public static byte[] CreateSuccessResponse<T>(int messageTag, T data)
|
||||
{
|
||||
return CreateResponseMessage(messageTag, 5, data.ToJson()); // 5 = Success
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error response message in MessagePack format.
|
||||
/// </summary>
|
||||
public static byte[] CreateErrorResponse(int messageTag)
|
||||
{
|
||||
return CreateResponseMessage(messageTag, 0, null); // 0 = Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a MessagePack message to IdMessage DTO.
|
||||
/// </summary>
|
||||
public static SignalRIdMessageDto? DeserializeToIdMessage(byte[] messageBytes)
|
||||
{
|
||||
if (messageBytes == null || messageBytes.Length == 0) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(messageBytes, ContractlessOptions);
|
||||
return postMessage.PostDataJson?.JsonTo<SignalRIdMessageDto>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a MessagePack response message.
|
||||
/// </summary>
|
||||
public static SignalRResponseDto? DeserializeResponse(byte[] messageBytes)
|
||||
{
|
||||
if (messageBytes == null || messageBytes.Length == 0) return null;
|
||||
|
||||
try
|
||||
{
|
||||
return MessagePackSerializer.Deserialize<SignalRResponseDto>(messageBytes, ContractlessOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight DTO for IdMessage serialization/deserialization in tests and benchmarks.
|
||||
/// Mirrors the structure of IdMessage without dependencies on AyCode.Services.
|
||||
/// </summary>
|
||||
public class SignalRIdMessageDto
|
||||
{
|
||||
public List<string> Ids { get; set; } = [];
|
||||
|
||||
public SignalRIdMessageDto()
|
||||
{
|
||||
}
|
||||
|
||||
public SignalRIdMessageDto(object[] ids)
|
||||
{
|
||||
Ids.AddRange(ids.Select(x => x.ToJson()));
|
||||
}
|
||||
|
||||
public SignalRIdMessageDto(object id)
|
||||
{
|
||||
Ids.Add(id.ToJson());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight DTO for SignalR post message serialization.
|
||||
/// Mirrors SignalPostJsonMessage structure.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class SignalRPostMessageDto
|
||||
{
|
||||
[Key(0)]
|
||||
public string? PostDataJson { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight DTO for SignalR response message serialization.
|
||||
/// Mirrors SignalResponseJsonMessage structure.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class SignalRResponseDto
|
||||
{
|
||||
[Key(0)]
|
||||
public int MessageTag { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public byte Status { get; set; }
|
||||
|
||||
[Key(2)]
|
||||
public string? ResponseData { get; set; }
|
||||
|
||||
[IgnoreMember]
|
||||
public bool IsSuccess => Status == 5;
|
||||
|
||||
[IgnoreMember]
|
||||
public bool IsError => Status == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Common SignalR message tags for testing.
|
||||
/// These mirror the production tags but are defined here for test/benchmark independence.
|
||||
/// </summary>
|
||||
public static class CommonSignalRTags
|
||||
{
|
||||
// Primitive parameter tags
|
||||
public const int SingleIntParam = 100;
|
||||
public const int TwoIntParams = 101;
|
||||
public const int BoolParam = 102;
|
||||
public const int StringParam = 103;
|
||||
public const int GuidParam = 104;
|
||||
public const int EnumParam = 105;
|
||||
public const int NoParams = 107;
|
||||
public const int MultipleTypesParams = 109;
|
||||
|
||||
// Extended primitives
|
||||
public const int DecimalParam = 140;
|
||||
public const int DateTimeParam = 141;
|
||||
public const int DoubleParam = 143;
|
||||
public const int LongParam = 144;
|
||||
|
||||
// Complex object tags
|
||||
public const int TestOrderItemParam = 120;
|
||||
public const int TestOrderParam = 121;
|
||||
public const int SharedTagParam = 122;
|
||||
|
||||
// Collection tags
|
||||
public const int IntArrayParam = 130;
|
||||
public const int GuidArrayParam = 131;
|
||||
public const int StringListParam = 132;
|
||||
public const int TestOrderItemListParam = 133;
|
||||
public const int IntListParam = 134;
|
||||
public const int BoolArrayParam = 135;
|
||||
public const int MixedWithArrayParam = 136;
|
||||
|
||||
// Mixed parameter scenarios
|
||||
public const int IntAndDtoParam = 160;
|
||||
public const int DtoAndListParam = 161;
|
||||
public const int ThreeComplexParams = 162;
|
||||
public const int FiveParams = 164;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-built test messages for benchmarking.
|
||||
/// Caches serialized messages to avoid setup overhead in benchmark iterations.
|
||||
/// </summary>
|
||||
public class SignalRBenchmarkData
|
||||
{
|
||||
// Pre-serialized messages
|
||||
public byte[] SingleIntMessage { get; }
|
||||
public byte[] TwoIntMessage { get; }
|
||||
public byte[] FiveParamsMessage { get; }
|
||||
public byte[] ComplexOrderItemMessage { get; }
|
||||
public byte[] ComplexOrderMessage { get; }
|
||||
public byte[] IntArrayMessage { get; }
|
||||
public byte[] MixedParamsMessage { get; }
|
||||
|
||||
// Test data
|
||||
public TestOrderItem TestOrderItem { get; }
|
||||
public TestOrder TestOrder { get; }
|
||||
public int[] IntArray { get; }
|
||||
public Guid TestGuid { get; }
|
||||
|
||||
public SignalRBenchmarkData()
|
||||
{
|
||||
// Create test data
|
||||
TestGuid = Guid.NewGuid();
|
||||
IntArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
TestOrderItem = new TestOrderItem
|
||||
{
|
||||
Id = 42,
|
||||
ProductName = "Benchmark Product",
|
||||
Quantity = 100,
|
||||
UnitPrice = 99.99m,
|
||||
Status = TestStatus.Active
|
||||
};
|
||||
TestOrder = TestDataFactory.CreateOrder(itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 2);
|
||||
|
||||
// Pre-serialize messages
|
||||
SingleIntMessage = SignalRMessageFactory.CreateSingleParamMessage(42);
|
||||
TwoIntMessage = SignalRMessageFactory.CreateIdMessage(10, 20);
|
||||
FiveParamsMessage = SignalRMessageFactory.CreateIdMessage(42, "hello", true, TestGuid, 99.99m);
|
||||
ComplexOrderItemMessage = SignalRMessageFactory.CreateComplexObjectMessage(TestOrderItem);
|
||||
ComplexOrderMessage = SignalRMessageFactory.CreateComplexObjectMessage(TestOrder);
|
||||
IntArrayMessage = SignalRMessageFactory.CreateComplexObjectMessage(IntArray);
|
||||
MixedParamsMessage = SignalRMessageFactory.CreateIdMessage(true, IntArray, "hello");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
namespace AyCode.Core.Tests.TestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating test data hierarchies.
|
||||
/// Used by both unit tests and benchmarks.
|
||||
/// </summary>
|
||||
public static class TestDataFactory
|
||||
{
|
||||
private static int _idCounter = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Reset the ID counter (call in test setup)
|
||||
/// </summary>
|
||||
public static void ResetIdCounter() => _idCounter = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Get the next unique ID
|
||||
/// </summary>
|
||||
public static int NextId() => _idCounter++;
|
||||
|
||||
#region Simple Object Creation
|
||||
|
||||
/// <summary>
|
||||
/// Create a shared tag for cross-reference testing
|
||||
/// </summary>
|
||||
public static SharedTag CreateTag(string? name = null, string? color = null)
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new SharedTag
|
||||
{
|
||||
Id = id,
|
||||
Name = name ?? $"Tag-{id}",
|
||||
Color = color ?? $"#{id:X2}{(id * 10) % 256:X2}{(id * 20) % 256:X2}",
|
||||
Priority = id % 5,
|
||||
IsActive = id % 2 == 0,
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-id),
|
||||
Description = $"Description for tag {id}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a shared category
|
||||
/// </summary>
|
||||
public static SharedCategory CreateCategory(string? name = null, int? parentId = null)
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new SharedCategory
|
||||
{
|
||||
Id = id,
|
||||
Name = name ?? $"Category-{id}",
|
||||
Description = $"Category description {id}",
|
||||
SortOrder = id * 100,
|
||||
IsDefault = id == 1,
|
||||
ParentCategoryId = parentId,
|
||||
CreatedAt = DateTime.UtcNow.AddMonths(-id),
|
||||
UpdatedAt = DateTime.UtcNow.AddDays(-id)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a shared user for cross-reference testing
|
||||
/// </summary>
|
||||
public static SharedUser CreateUser(string? username = null, TestUserRole role = TestUserRole.User)
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new SharedUser
|
||||
{
|
||||
Id = id,
|
||||
Username = username ?? $"user{id}",
|
||||
Email = $"user{id}@test.com",
|
||||
FirstName = $"First{id}",
|
||||
LastName = $"Last{id}",
|
||||
IsActive = true,
|
||||
Role = role,
|
||||
LastLoginAt = DateTime.UtcNow.AddHours(-id),
|
||||
CreatedAt = DateTime.UtcNow.AddYears(-1),
|
||||
Preferences = new UserPreferences
|
||||
{
|
||||
Theme = id % 2 == 0 ? "dark" : "light",
|
||||
Language = "en-US",
|
||||
NotificationsEnabled = true,
|
||||
EmailDigestFrequency = "daily"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create metadata info (non-IId)
|
||||
/// </summary>
|
||||
public static MetadataInfo CreateMetadata(string? key = null, bool withChild = false)
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new MetadataInfo
|
||||
{
|
||||
Key = key ?? $"Meta-{id}",
|
||||
Value = $"MetaValue-{id}",
|
||||
Timestamp = DateTime.UtcNow.AddMinutes(-id * 10),
|
||||
ChildMetadata = withChild ? CreateMetadata($"Child-{id}", false) : null
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Hierarchy Creation (5 Levels)
|
||||
|
||||
/// <summary>
|
||||
/// Create a deep order hierarchy with configurable depth
|
||||
/// </summary>
|
||||
public static TestOrder CreateOrder(
|
||||
int itemCount = 2,
|
||||
int palletsPerItem = 2,
|
||||
int measurementsPerPallet = 2,
|
||||
int pointsPerMeasurement = 3,
|
||||
SharedTag? sharedTag = null,
|
||||
SharedUser? sharedUser = null,
|
||||
MetadataInfo? sharedMetadata = null)
|
||||
{
|
||||
var order = new TestOrder
|
||||
{
|
||||
Id = _idCounter++,
|
||||
OrderNumber = $"ORD-{_idCounter:D4}",
|
||||
Status = TestStatus.Pending,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
TotalAmount = 1000m + _idCounter * 100,
|
||||
PrimaryTag = sharedTag,
|
||||
SecondaryTag = sharedTag, // Same reference for $ref testing
|
||||
Owner = sharedUser,
|
||||
OrderMetadata = sharedMetadata,
|
||||
AuditMetadata = sharedMetadata // Same reference for Newtonsoft $ref
|
||||
};
|
||||
|
||||
if (sharedTag != null)
|
||||
{
|
||||
order.Tags.Add(sharedTag);
|
||||
}
|
||||
|
||||
for (int i = 0; i < itemCount; i++)
|
||||
{
|
||||
var item = CreateOrderItem(palletsPerItem, measurementsPerPallet, pointsPerMeasurement, sharedTag, sharedUser, sharedMetadata);
|
||||
item.ParentOrder = order;
|
||||
order.Items.Add(item);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an order item with pallets
|
||||
/// </summary>
|
||||
public static TestOrderItem CreateOrderItem(
|
||||
int palletCount = 2,
|
||||
int measurementsPerPallet = 2,
|
||||
int pointsPerMeasurement = 3,
|
||||
SharedTag? sharedTag = null,
|
||||
SharedUser? sharedUser = null,
|
||||
MetadataInfo? sharedMetadata = null)
|
||||
{
|
||||
var item = new TestOrderItem
|
||||
{
|
||||
Id = _idCounter++,
|
||||
ProductName = $"Product-{_idCounter}",
|
||||
Quantity = 10 + _idCounter,
|
||||
UnitPrice = 5.5m * _idCounter,
|
||||
Status = TestStatus.Pending,
|
||||
Tag = sharedTag,
|
||||
Assignee = sharedUser,
|
||||
ItemMetadata = sharedMetadata
|
||||
};
|
||||
|
||||
for (int i = 0; i < palletCount; i++)
|
||||
{
|
||||
var pallet = CreatePallet(measurementsPerPallet, pointsPerMeasurement, sharedMetadata);
|
||||
pallet.ParentItem = item;
|
||||
item.Pallets.Add(pallet);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a pallet with measurements
|
||||
/// </summary>
|
||||
public static TestPallet CreatePallet(
|
||||
int measurementCount = 2,
|
||||
int pointsPerMeasurement = 3,
|
||||
MetadataInfo? sharedMetadata = null)
|
||||
{
|
||||
var pallet = new TestPallet
|
||||
{
|
||||
Id = _idCounter++,
|
||||
PalletCode = $"PLT-{_idCounter:D4}",
|
||||
TrayCount = 5 + _idCounter % 10,
|
||||
Status = TestStatus.Pending,
|
||||
Weight = 100.5 + _idCounter,
|
||||
PalletMetadata = sharedMetadata
|
||||
};
|
||||
|
||||
for (int i = 0; i < measurementCount; i++)
|
||||
{
|
||||
var measurement = CreateMeasurement(pointsPerMeasurement);
|
||||
measurement.ParentPallet = pallet;
|
||||
pallet.Measurements.Add(measurement);
|
||||
}
|
||||
|
||||
return pallet;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a measurement with points
|
||||
/// </summary>
|
||||
public static TestMeasurement CreateMeasurement(int pointCount = 3)
|
||||
{
|
||||
var measurement = new TestMeasurement
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Name = $"Measurement-{_idCounter}",
|
||||
TotalWeight = 100.5 + _idCounter,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
for (int i = 0; i < pointCount; i++)
|
||||
{
|
||||
var point = CreateMeasurementPoint();
|
||||
point.ParentMeasurement = measurement;
|
||||
measurement.Points.Add(point);
|
||||
}
|
||||
|
||||
return measurement;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a measurement point
|
||||
/// </summary>
|
||||
public static TestMeasurementPoint CreateMeasurementPoint()
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new TestMeasurementPoint
|
||||
{
|
||||
Id = id,
|
||||
Label = $"Point-{id}",
|
||||
Value = 10.5 + (id * 0.1),
|
||||
MeasuredAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Benchmark Data Generation
|
||||
|
||||
/// <summary>
|
||||
/// Create a large graph for benchmarking with many cross-references.
|
||||
/// Creates approximately (itemCount * palletsPerItem * measurementsPerPallet * pointsPerMeasurement) objects.
|
||||
/// </summary>
|
||||
public static TestOrder CreateBenchmarkOrder(
|
||||
int itemCount = 5,
|
||||
int palletsPerItem = 4,
|
||||
int measurementsPerPallet = 3,
|
||||
int pointsPerMeasurement = 5)
|
||||
{
|
||||
ResetIdCounter();
|
||||
|
||||
// Create shared references that will be used throughout
|
||||
var sharedTags = Enumerable.Range(1, 10).Select(_ => CreateTag()).ToList();
|
||||
var sharedUser = CreateUser("benchuser", TestUserRole.Admin);
|
||||
var sharedMetadata = CreateMetadata("benchmark", withChild: true);
|
||||
|
||||
var order = new TestOrder
|
||||
{
|
||||
Id = _idCounter++,
|
||||
OrderNumber = $"BENCH-{_idCounter:D6}",
|
||||
Status = TestStatus.Processing,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
TotalAmount = 999999.99m,
|
||||
PrimaryTag = sharedTags[0],
|
||||
SecondaryTag = sharedTags[0],
|
||||
Owner = sharedUser,
|
||||
Category = CreateCategory("Benchmark"),
|
||||
OrderMetadata = sharedMetadata,
|
||||
AuditMetadata = sharedMetadata,
|
||||
Tags = sharedTags.Take(3).ToList()
|
||||
};
|
||||
|
||||
for (int i = 0; i < itemCount; i++)
|
||||
{
|
||||
var item = new TestOrderItem
|
||||
{
|
||||
Id = _idCounter++,
|
||||
ProductName = $"BenchProduct-{i}",
|
||||
Quantity = 100 + i * 10,
|
||||
UnitPrice = 25.99m + i,
|
||||
Status = (TestStatus)(i % 5),
|
||||
Tag = sharedTags[i % sharedTags.Count],
|
||||
Assignee = sharedUser,
|
||||
ItemMetadata = sharedMetadata
|
||||
};
|
||||
item.ParentOrder = order;
|
||||
|
||||
for (int p = 0; p < palletsPerItem; p++)
|
||||
{
|
||||
var pallet = new TestPallet
|
||||
{
|
||||
Id = _idCounter++,
|
||||
PalletCode = $"PLT-{i}-{p}",
|
||||
TrayCount = 10 + p,
|
||||
Status = (TestStatus)(p % 4),
|
||||
Weight = 500.0 + p * 50,
|
||||
PalletMetadata = sharedMetadata
|
||||
};
|
||||
pallet.ParentItem = item;
|
||||
|
||||
for (int m = 0; m < measurementsPerPallet; m++)
|
||||
{
|
||||
var measurement = new TestMeasurement
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Name = $"Meas-{i}-{p}-{m}",
|
||||
TotalWeight = 50.0 + m * 10,
|
||||
CreatedAt = DateTime.UtcNow.AddMinutes(-m)
|
||||
};
|
||||
measurement.ParentPallet = pallet;
|
||||
|
||||
for (int pt = 0; pt < pointsPerMeasurement; pt++)
|
||||
{
|
||||
var point = new TestMeasurementPoint
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Label = $"Pt-{i}-{p}-{m}-{pt}",
|
||||
Value = 1.0 + pt * 0.5,
|
||||
MeasuredAt = DateTime.UtcNow.AddSeconds(-pt)
|
||||
};
|
||||
point.ParentMeasurement = measurement;
|
||||
measurement.Points.Add(point);
|
||||
}
|
||||
pallet.Measurements.Add(measurement);
|
||||
}
|
||||
item.Pallets.Add(pallet);
|
||||
}
|
||||
order.Items.Add(item);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create primitive test data for all-types testing
|
||||
/// </summary>
|
||||
public static PrimitiveTestClass CreatePrimitiveTestData()
|
||||
{
|
||||
return new PrimitiveTestClass
|
||||
{
|
||||
IntValue = int.MaxValue,
|
||||
LongValue = long.MaxValue,
|
||||
DoubleValue = 3.14159265358979,
|
||||
DecimalValue = 12345.6789m,
|
||||
FloatValue = 1.5f,
|
||||
BoolValue = true,
|
||||
StringValue = "Test String ?? ????",
|
||||
GuidValue = Guid.Parse("12345678-1234-1234-1234-123456789abc"),
|
||||
DateTimeValue = new DateTime(2024, 12, 25, 12, 30, 45, DateTimeKind.Utc),
|
||||
EnumValue = TestStatus.Shipped,
|
||||
ByteValue = 255,
|
||||
ShortValue = short.MaxValue,
|
||||
NullableInt = 42,
|
||||
NullableIntNull = null
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using AyCode.Core.Enums;
|
||||
using AyCode.Core.Loggers;
|
||||
|
||||
namespace AyCode.Core.Tests.TestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Test logger that captures log messages for assertions.
|
||||
/// Does not require configuration or log writers.
|
||||
/// </summary>
|
||||
public class TestLogger : AcLoggerBase
|
||||
{
|
||||
public List<LogEntry> Logs { get; } = [];
|
||||
|
||||
public TestLogger() : base(AppType.Server, LogLevel.Detail, "TestLogger")
|
||||
{
|
||||
}
|
||||
|
||||
public override void Detail(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||
=> Logs.Add(new LogEntry(LogLevel.Detail, text, categoryName, memberName));
|
||||
|
||||
public override void Debug(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||
=> Logs.Add(new LogEntry(LogLevel.Debug, text, categoryName, memberName));
|
||||
|
||||
public override void Info(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||
=> Logs.Add(new LogEntry(LogLevel.Info, text, categoryName, memberName));
|
||||
|
||||
public override void Warning(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||
=> Logs.Add(new LogEntry(LogLevel.Warning, text, categoryName, memberName));
|
||||
|
||||
public override void Suggest(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||
=> Logs.Add(new LogEntry(LogLevel.Suggest, text, categoryName, memberName));
|
||||
|
||||
public override void Error(string? text, Exception? ex = null, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||
=> Logs.Add(new LogEntry(LogLevel.Error, text, categoryName, memberName, ex));
|
||||
|
||||
public void Clear() => Logs.Clear();
|
||||
|
||||
public bool HasErrorLogs => Logs.Any(l => l.Level == LogLevel.Error);
|
||||
public bool HasWarningLogs => Logs.Any(l => l.Level == LogLevel.Warning);
|
||||
public IEnumerable<LogEntry> ErrorLogs => Logs.Where(l => l.Level == LogLevel.Error);
|
||||
public IEnumerable<LogEntry> WarningLogs => Logs.Where(l => l.Level == LogLevel.Warning);
|
||||
|
||||
public IEnumerable<string> GetErrorMessages() => ErrorLogs.Select(l => $"{l.Text} {l.Exception?.Message}");
|
||||
public IEnumerable<string> GetAllMessages() => Logs.Select(l => l.ToString());
|
||||
}
|
||||
|
||||
public record LogEntry(
|
||||
LogLevel Level,
|
||||
string? Text,
|
||||
string? CategoryName = null,
|
||||
string? MemberName = null,
|
||||
Exception? Exception = null)
|
||||
{
|
||||
public override string ToString() => $"[{Level}] {Text}";
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 18
|
||||
VisualStudioVersion = 18.0.11222.15 d18.0
|
||||
VisualStudioVersion = 18.0.11222.15
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AyCode.Core", "AyCode.Core\AyCode.Core.csproj", "{8CCC4969-7306-4747-8A58-80AC5A062EE1}"
|
||||
EndProject
|
||||
|
|
@ -46,6 +46,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BenchmarkSuite1", "BenchmarkSuite1\BenchmarkSuite1.csproj", "{A20861A9-411E-6150-BF5C-69E8196E5D22}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AyCode.Services.Tests", "AyCode.Services.Tests\AyCode.Services.Tests.csproj", "{B8443014-1247-FB9C-7BF4-2CC944075A8B}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
@ -156,7 +158,11 @@ Global
|
|||
{A20861A9-411E-6150-BF5C-69E8196E5D22}.Product|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A20861A9-411E-6150-BF5C-69E8196E5D22}.Product|Any CPU.Build.0 = Release|Any CPU
|
||||
{A20861A9-411E-6150-BF5C-69E8196E5D22}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A20861A9-411E-6150-BF5C-69E8196E5D22}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B8443014-1247-FB9C-7BF4-2CC944075A8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B8443014-1247-FB9C-7BF4-2CC944075A8B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B8443014-1247-FB9C-7BF4-2CC944075A8B}.Product|Any CPU.ActiveCfg = Product|Any CPU
|
||||
{B8443014-1247-FB9C-7BF4-2CC944075A8B}.Product|Any CPU.Build.0 = Product|Any CPU
|
||||
{B8443014-1247-FB9C-7BF4-2CC944075A8B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -27,11 +27,18 @@ public static class AcJsonSerializer
|
|||
|
||||
/// <summary>
|
||||
/// Serialize object to JSON string with optimized reference handling.
|
||||
/// Supports primitives, strings, enums, and complex objects.
|
||||
/// </summary>
|
||||
public static string Serialize<T>(T value)
|
||||
{
|
||||
if (value == null) return "null";
|
||||
|
||||
var type = typeof(T);
|
||||
|
||||
// Fast path for primitives - no reference tracking needed
|
||||
if (TrySerializePrimitive(value, type, out var primitiveJson))
|
||||
return primitiveJson;
|
||||
|
||||
var context = new SerializationContext();
|
||||
|
||||
// Phase 1: Scan for cross-references (objects that appear multiple times)
|
||||
|
|
@ -44,6 +51,185 @@ public static class AcJsonSerializer
|
|||
return context.GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to serialize a primitive value directly without context.
|
||||
/// Returns true if value is primitive, false otherwise.
|
||||
/// </summary>
|
||||
private static bool TrySerializePrimitive<T>(T value, Type type, out string json)
|
||||
{
|
||||
// Handle nullable underlying type
|
||||
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
|
||||
|
||||
if (underlyingType == typeof(string))
|
||||
{
|
||||
json = SerializeString((string)(object)value!);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(int))
|
||||
{
|
||||
json = ((int)(object)value!).ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(long))
|
||||
{
|
||||
json = ((long)(object)value!).ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(bool))
|
||||
{
|
||||
json = (bool)(object)value! ? "true" : "false";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(double))
|
||||
{
|
||||
var d = (double)(object)value!;
|
||||
json = double.IsNaN(d) || double.IsInfinity(d) ? "null" : d.ToString("G17", CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(decimal))
|
||||
{
|
||||
json = ((decimal)(object)value!).ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(float))
|
||||
{
|
||||
var f = (float)(object)value!;
|
||||
json = float.IsNaN(f) || float.IsInfinity(f) ? "null" : f.ToString("G9", CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(DateTime))
|
||||
{
|
||||
json = $"\"{((DateTime)(object)value!).ToString("O", CultureInfo.InvariantCulture)}\"";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(DateTimeOffset))
|
||||
{
|
||||
json = $"\"{((DateTimeOffset)(object)value!).ToString("O", CultureInfo.InvariantCulture)}\"";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(Guid))
|
||||
{
|
||||
json = $"\"{((Guid)(object)value!).ToString("D")}\"";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(TimeSpan))
|
||||
{
|
||||
json = $"\"{((TimeSpan)(object)value!).ToString("c", CultureInfo.InvariantCulture)}\"";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType.IsEnum)
|
||||
{
|
||||
json = Convert.ToInt32(value).ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(byte))
|
||||
{
|
||||
json = ((byte)(object)value!).ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(short))
|
||||
{
|
||||
json = ((short)(object)value!).ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(ushort))
|
||||
{
|
||||
json = ((ushort)(object)value!).ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(uint))
|
||||
{
|
||||
json = ((uint)(object)value!).ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(ulong))
|
||||
{
|
||||
json = ((ulong)(object)value!).ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(sbyte))
|
||||
{
|
||||
json = ((sbyte)(object)value!).ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(char))
|
||||
{
|
||||
json = SerializeString(value!.ToString()!);
|
||||
return true;
|
||||
}
|
||||
|
||||
json = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize a string value with proper escaping.
|
||||
/// </summary>
|
||||
private static string SerializeString(string value)
|
||||
{
|
||||
// Fast path: if no escaping needed
|
||||
if (!NeedsEscaping(value))
|
||||
return $"\"{value}\"";
|
||||
|
||||
var sb = new StringBuilder(value.Length + 2);
|
||||
sb.Append('"');
|
||||
foreach (var c in value)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case '"': sb.Append("\\\""); break;
|
||||
case '\\': sb.Append("\\\\"); break;
|
||||
case '\b': sb.Append("\\b"); break;
|
||||
case '\f': sb.Append("\\f"); break;
|
||||
case '\n': sb.Append("\\n"); break;
|
||||
case '\r': sb.Append("\\r"); break;
|
||||
case '\t': sb.Append("\\t"); break;
|
||||
default:
|
||||
if (c < 32)
|
||||
{
|
||||
sb.Append("\\u");
|
||||
sb.Append(((int)c).ToString("X4"));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
sb.Append('"');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static bool NeedsEscaping(string value)
|
||||
{
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (c < 32 || c == '"' || c == '\\')
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#region Phase 1: Reference Scanning
|
||||
|
||||
private static void ScanReferences(object? value, SerializationContext context)
|
||||
|
|
@ -52,17 +238,17 @@ public static class AcJsonSerializer
|
|||
|
||||
var type = value.GetType();
|
||||
|
||||
// Skip primitives
|
||||
if (IsPrimitiveOrString(type)) return;
|
||||
// Skip primitives - use cached type check
|
||||
if (IsPrimitiveOrStringFast(type)) return;
|
||||
|
||||
// Track object occurrence
|
||||
// Track object occurrence - inline the check
|
||||
if (!context.TrackForScanning(value))
|
||||
{
|
||||
// Already seen - mark as needing $id
|
||||
return;
|
||||
}
|
||||
|
||||
// Scan collections
|
||||
// Scan collections - check IEnumerable before getting metadata
|
||||
if (value is IEnumerable enumerable && type != typeof(string))
|
||||
{
|
||||
foreach (var item in enumerable)
|
||||
|
|
@ -73,16 +259,35 @@ public static class AcJsonSerializer
|
|||
return;
|
||||
}
|
||||
|
||||
// Scan object properties
|
||||
// Scan object properties using cached metadata
|
||||
var metadata = GetTypeMetadata(type);
|
||||
foreach (var prop in metadata.Properties)
|
||||
var properties = metadata.Properties;
|
||||
var propCount = properties.Length;
|
||||
|
||||
// Unroll small property counts for better performance
|
||||
for (var i = 0; i < propCount; i++)
|
||||
{
|
||||
var propValue = prop.GetValue(value);
|
||||
var propValue = properties[i].GetValue(value);
|
||||
if (propValue != null)
|
||||
ScanReferences(propValue, context);
|
||||
}
|
||||
}
|
||||
|
||||
// Faster primitive check using type code
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static bool IsPrimitiveOrStringFast(Type type)
|
||||
{
|
||||
var typeCode = Type.GetTypeCode(type);
|
||||
return typeCode switch
|
||||
{
|
||||
TypeCode.Boolean or TypeCode.Char or TypeCode.SByte or TypeCode.Byte or
|
||||
TypeCode.Int16 or TypeCode.UInt16 or TypeCode.Int32 or TypeCode.UInt32 or
|
||||
TypeCode.Int64 or TypeCode.UInt64 or TypeCode.Single or TypeCode.Double or
|
||||
TypeCode.Decimal or TypeCode.DateTime or TypeCode.String => true,
|
||||
_ => type == typeof(Guid) || type == typeof(TimeSpan) || type == typeof(DateTimeOffset) || type.IsEnum
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Phase 2: Serialization
|
||||
|
|
@ -101,6 +306,13 @@ public static class AcJsonSerializer
|
|||
if (TryWritePrimitive(value, type, context))
|
||||
return;
|
||||
|
||||
// Dictionaries - must check before IEnumerable since Dictionary implements IEnumerable
|
||||
if (value is IDictionary dictionary)
|
||||
{
|
||||
WriteDictionary(dictionary, context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collections
|
||||
if (value is IEnumerable enumerable && type != typeof(string))
|
||||
{
|
||||
|
|
@ -166,6 +378,24 @@ public static class AcJsonSerializer
|
|||
context.WriteArrayEnd();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write a dictionary as a JSON object with keys as property names.
|
||||
/// </summary>
|
||||
private static void WriteDictionary(IDictionary dictionary, SerializationContext context)
|
||||
{
|
||||
context.WriteObjectStart();
|
||||
var isFirst = true;
|
||||
|
||||
foreach (DictionaryEntry entry in dictionary)
|
||||
{
|
||||
var keyString = entry.Key?.ToString() ?? "";
|
||||
context.WritePropertyName(keyString, ref isFirst);
|
||||
WriteValue(entry.Value, context);
|
||||
}
|
||||
|
||||
context.WriteObjectEnd();
|
||||
}
|
||||
|
||||
private static bool TryWritePrimitive(object value, Type type, SerializationContext context)
|
||||
{
|
||||
// Handle nullable underlying type
|
||||
|
|
@ -292,10 +522,7 @@ public static class AcJsonSerializer
|
|||
private static bool IsPrimitiveOrString(Type type)
|
||||
{
|
||||
var t = Nullable.GetUnderlyingType(type) ?? type;
|
||||
return t.IsPrimitive || t.IsEnum ||
|
||||
t == typeof(string) || t == typeof(decimal) ||
|
||||
t == typeof(DateTime) || t == typeof(DateTimeOffset) ||
|
||||
t == typeof(Guid) || t == typeof(TimeSpan);
|
||||
return IsPrimitiveOrStringFast(t);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -463,17 +690,18 @@ public static class AcJsonSerializer
|
|||
private readonly Dictionary<object, string> _writtenRefs;
|
||||
private readonly HashSet<object> _multiReferenced;
|
||||
private int _nextId;
|
||||
private bool _isWriting;
|
||||
|
||||
// Pre-allocated char buffers for number formatting
|
||||
private readonly char[] _numberBuffer = new char[32];
|
||||
// Use ArrayPool for number buffer to reduce allocations
|
||||
private static readonly ArrayPool<char> CharPool = ArrayPool<char>.Shared;
|
||||
private readonly char[] _numberBuffer;
|
||||
|
||||
public SerializationContext()
|
||||
{
|
||||
_sb = new StringBuilder(4096);
|
||||
_scanOccurrences = new Dictionary<object, int>(ReferenceEqualityComparer.Instance);
|
||||
_writtenRefs = new Dictionary<object, string>(ReferenceEqualityComparer.Instance);
|
||||
_multiReferenced = new HashSet<object>(ReferenceEqualityComparer.Instance);
|
||||
_scanOccurrences = new Dictionary<object, int>(64, ReferenceEqualityComparer.Instance);
|
||||
_writtenRefs = new Dictionary<object, string>(32, ReferenceEqualityComparer.Instance);
|
||||
_multiReferenced = new HashSet<object>(32, ReferenceEqualityComparer.Instance);
|
||||
_numberBuffer = CharPool.Rent(64);
|
||||
_nextId = 1;
|
||||
}
|
||||
|
||||
|
|
@ -482,18 +710,18 @@ public static class AcJsonSerializer
|
|||
/// </summary>
|
||||
public bool TrackForScanning(object obj)
|
||||
{
|
||||
if (_scanOccurrences.TryGetValue(obj, out var count))
|
||||
ref var count = ref System.Runtime.InteropServices.CollectionsMarshal.GetValueRefOrAddDefault(_scanOccurrences, obj, out var exists);
|
||||
if (exists)
|
||||
{
|
||||
_scanOccurrences[obj] = count + 1;
|
||||
count++;
|
||||
_multiReferenced.Add(obj);
|
||||
return false;
|
||||
}
|
||||
|
||||
_scanOccurrences[obj] = 1;
|
||||
count = 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void StartWriting() => _isWriting = true;
|
||||
public void StartWriting() { }
|
||||
|
||||
/// <summary>
|
||||
/// Check if this object needs a $id (is referenced elsewhere).
|
||||
|
|
@ -526,7 +754,12 @@ public static class AcJsonSerializer
|
|||
_sb.Append("\"}");
|
||||
}
|
||||
|
||||
public string GetResult() => _sb.ToString();
|
||||
public string GetResult()
|
||||
{
|
||||
var result = _sb.ToString();
|
||||
CharPool.Return(_numberBuffer);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Write methods
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
|
|
|
|||
|
|
@ -417,10 +417,10 @@ public static class SerializeObjectExtensions
|
|||
public static string ToJson<T>(this T source, JsonSerializerSettings? options = null)
|
||||
{
|
||||
// If custom options are provided, use Newtonsoft for full compatibility
|
||||
if (options != null)
|
||||
{
|
||||
return JsonConvert.SerializeObject(source, options);
|
||||
}
|
||||
//if (options != null)
|
||||
//{
|
||||
// return JsonConvert.SerializeObject(source, options);
|
||||
//}
|
||||
|
||||
// Use our high-performance custom serializer
|
||||
return AcJsonSerializer.Serialize(source);
|
||||
|
|
@ -466,101 +466,63 @@ public static class SerializeObjectExtensions
|
|||
}
|
||||
|
||||
public static string ToJson<T>(this IQueryable<T> source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson
|
||||
=> options != null ? JsonConvert.SerializeObject(source, options) : AcJsonSerializer.Serialize(source);
|
||||
// OLD: => ((object)source).ToJson(options);
|
||||
//=> options != null ? JsonConvert.SerializeObject(source, options) : AcJsonSerializer.Serialize(source);
|
||||
=> ((object)source).ToJson(options);
|
||||
|
||||
public static string ToJson<T>(this IEnumerable<T> source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson
|
||||
=> options != null ? JsonConvert.SerializeObject(source, options) : AcJsonSerializer.Serialize(source);
|
||||
// OLD: => ((object)source).ToJson(options);
|
||||
//=> options != null ? JsonConvert.SerializeObject(source, options) : AcJsonSerializer.Serialize(source);
|
||||
=> ((object)source).ToJson(options);
|
||||
|
||||
public static T? JsonTo<T>(this string json, JsonSerializerSettings? options = null)
|
||||
{
|
||||
json = JsonUtilities.UnwrapJsonString(json);
|
||||
return AcJsonDeserializer.Deserialize<T>(json);
|
||||
|
||||
// Use our high-performance custom deserializer
|
||||
// AcJsonDeserializer now supports primitives, enums, and complex types
|
||||
//if (options == null)
|
||||
//{
|
||||
// try
|
||||
// {
|
||||
// return AcJsonDeserializer.Deserialize<T>(json);
|
||||
// }
|
||||
// catch
|
||||
// {
|
||||
// // Fallback to Newtonsoft if custom deserializer fails
|
||||
// }
|
||||
//}
|
||||
|
||||
// Use our high-performance custom deserializer for simple deserialization
|
||||
// Only use for: non-abstract classes, with parameterless constructor, not arrays, not primitives
|
||||
if (options == null &&
|
||||
typeof(T).IsClass &&
|
||||
!typeof(T).IsAbstract &&
|
||||
!typeof(T).IsArray &&
|
||||
!typeof(T).IsPrimitive &&
|
||||
typeof(T) != typeof(string) &&
|
||||
typeof(T).GetConstructor(Type.EmptyTypes) != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (T?)AcJsonDeserializer.Deserialize(json, typeof(T));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to Newtonsoft if custom deserializer fails
|
||||
}
|
||||
}
|
||||
|
||||
return JsonConvert.DeserializeObject<T>(json, options ?? Options);
|
||||
|
||||
// ========================================================================
|
||||
// OLD IMPLEMENTATION - Always Newtonsoft
|
||||
// Uncomment below and comment out the above to rollback
|
||||
// ========================================================================
|
||||
// return JsonConvert.DeserializeObject<T>(json, options ?? Options);
|
||||
//return JsonConvert.DeserializeObject<T>(json, options ?? Options);
|
||||
}
|
||||
|
||||
public static object? JsonTo(this string json, Type toType, JsonSerializerSettings? options = null)
|
||||
{
|
||||
json = JsonUtilities.UnwrapJsonString(json);
|
||||
return AcJsonDeserializer.Deserialize(json, toType);
|
||||
|
||||
//// Use our high-performance custom deserializer
|
||||
//// AcJsonDeserializer now supports primitives, enums, and complex types
|
||||
//if (options == null)
|
||||
//{
|
||||
// try
|
||||
// {
|
||||
// return AcJsonDeserializer.Deserialize(json, toType);
|
||||
// }
|
||||
// catch
|
||||
// {
|
||||
// // Fallback to Newtonsoft if custom deserializer fails
|
||||
// }
|
||||
//}
|
||||
|
||||
// Use our high-performance custom deserializer for simple deserialization
|
||||
// Only use for: non-abstract classes, with parameterless constructor, not arrays, not primitives
|
||||
if (options == null &&
|
||||
toType.IsClass &&
|
||||
!toType.IsAbstract &&
|
||||
!toType.IsArray &&
|
||||
!toType.IsPrimitive &&
|
||||
toType != typeof(string) &&
|
||||
toType.GetConstructor(Type.EmptyTypes) != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return AcJsonDeserializer.Deserialize(json, toType);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to Newtonsoft if custom deserializer fails
|
||||
}
|
||||
}
|
||||
|
||||
return JsonConvert.DeserializeObject(json, toType, options ?? Options);
|
||||
|
||||
// ========================================================================
|
||||
// OLD IMPLEMENTATION - Always Newtonsoft
|
||||
// Uncomment below and comment out the above to rollback
|
||||
// ========================================================================
|
||||
// return JsonConvert.DeserializeObject(json, toType, options ?? Options);
|
||||
//return JsonConvert.DeserializeObject(json, toType, options ?? Options);
|
||||
}
|
||||
|
||||
public static void JsonTo(this string json, object target, JsonSerializerSettings? options = null)
|
||||
{
|
||||
json = JsonUtilities.UnwrapJsonString(json);
|
||||
|
||||
// For populate/merge, we still use Newtonsoft as it handles complex merge logic
|
||||
// The AcJsonDeserializer.Populate can be used for simple cases
|
||||
target.DeepPopulateWithMerge(json, options ?? Options);
|
||||
|
||||
// ========================================================================
|
||||
// ALTERNATIVE - Use AcJsonDeserializer for populate (simpler merge logic)
|
||||
// Uncomment below for faster but simpler merge
|
||||
// ========================================================================
|
||||
// if (options == null)
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// AcJsonDeserializer.Populate(json, target);
|
||||
// return;
|
||||
// }
|
||||
// catch { }
|
||||
// }
|
||||
// target.DeepPopulateWithMerge(json, options ?? Options);
|
||||
// Use runtime type instead of compile-time type for Populate
|
||||
AcJsonDeserializer.Populate(json, target);
|
||||
}
|
||||
|
||||
[return: NotNullIfNotNull(nameof(src))]
|
||||
|
|
@ -570,9 +532,9 @@ public static class SerializeObjectExtensions
|
|||
public static void CopyTo(this object? src, object target, JsonSerializerSettings? options = null)
|
||||
=> src?.ToJson(options).JsonTo(target, options);
|
||||
|
||||
public static byte[] ToMessagePack(this object message) => MessagePackSerializer.Serialize(message);
|
||||
//public static byte[] ToMessagePack(this object message) => MessagePackSerializer.Serialize(message);
|
||||
public static byte[] ToMessagePack(this object message, MessagePackSerializerOptions options) => MessagePackSerializer.Serialize(message, options);
|
||||
public static T MessagePackTo<T>(this byte[] message) => MessagePackSerializer.Deserialize<T>(message);
|
||||
//public static T MessagePackTo<T>(this byte[] message) => MessagePackSerializer.Deserialize<T>(message);
|
||||
public static T MessagePackTo<T>(this byte[] message, MessagePackSerializerOptions options) => MessagePackSerializer.Deserialize<T>(message, options);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,20 +5,47 @@
|
|||
public static bool WaitTo(Func<bool> predicate, int msTimeout = 10000, int msDelay = 5, int msFirstDelay = 0)
|
||||
=> WaitToAsync(predicate, msTimeout, msDelay, msFirstDelay).GetAwaiter().GetResult();
|
||||
|
||||
public static bool WaitTo(Func<bool> predicate, int msTimeout, int msDelay, int msFirstDelay, CancellationToken cancellationToken)
|
||||
=> WaitToAsync(predicate, msTimeout, msDelay, msFirstDelay, cancellationToken).GetAwaiter().GetResult();
|
||||
|
||||
public static Task<bool> WaitToAsync(Func<bool> predicate, int msTimeout = 10000, int msDelay = 5, int msFirstDelay = 0)
|
||||
=> WaitToAsync(predicate, msTimeout, msDelay, msFirstDelay, CancellationToken.None);
|
||||
|
||||
public static async Task<bool> WaitToAsync(Func<bool> predicate, int msTimeout, int msDelay, int msFirstDelay, CancellationToken cancellationToken)
|
||||
{
|
||||
return ToThreadPoolTask(async () =>
|
||||
// Use Environment.TickCount64 instead of DateTime.UtcNow.Ticks for better performance
|
||||
var endTick = Environment.TickCount64 + msTimeout;
|
||||
|
||||
if (msFirstDelay > 0)
|
||||
await Task.Delay(msFirstDelay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check immediately first
|
||||
if (predicate())
|
||||
return true;
|
||||
|
||||
// Use PeriodicTimer for efficient polling (.NET 6+)
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(msDelay));
|
||||
|
||||
while (Environment.TickCount64 < endTick)
|
||||
{
|
||||
var result = false;
|
||||
var dtTimeout = DateTime.UtcNow.AddMilliseconds(msTimeout).Ticks;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (predicate())
|
||||
return true;
|
||||
|
||||
if (msFirstDelay > 0) await Task.Delay(msFirstDelay).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (!await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while (dtTimeout > DateTime.UtcNow.Ticks && !(result = predicate()))
|
||||
await Task.Delay(msDelay).ConfigureAwait(false); //Thread.Sleep(msDelay);
|
||||
|
||||
return result;
|
||||
});
|
||||
// Final check
|
||||
return predicate();
|
||||
}
|
||||
|
||||
public static void Forget(this Task task)
|
||||
|
|
@ -32,43 +59,36 @@
|
|||
{
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch
|
||||
{
|
||||
await Task.FromException(ex).ConfigureAwait(true);
|
||||
// Swallow exception - fire and forget semantics
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//public static void Forget(this ValueTask task)
|
||||
//{
|
||||
// if (!task.IsCompleted || task.IsFaulted)
|
||||
// _ = ForgetAwaited(task);
|
||||
public static void Forget(this ValueTask task)
|
||||
{
|
||||
if (!task.IsCompleted || task.IsFaulted)
|
||||
_ = ForgetAwaited(task);
|
||||
|
||||
// static async ValueTask ForgetAwaited(ValueTask task)
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// await task.ConfigureAwait(false);
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// //TODO: .net5, .net6 feature! - J.
|
||||
// ValueTask.FromException(ex).ConfigureAwait(true);
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
static async Task ForgetAwaited(ValueTask task)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow exception - fire and forget semantics
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: Cancellation token params - J.
|
||||
//public static void RunOnThreadPool(this Task task) => Task.Run(() => _ = task).Forget(); //TODO: Letesztelni, a ThreadId-kat! - J.
|
||||
public static void RunOnThreadPool(this Action action) => ToThreadPoolTask(action).Forget();
|
||||
public static void RunOnThreadPool<T>(this Func<T> func) => ToThreadPoolTask(func).Forget();
|
||||
|
||||
public static Task ToThreadPoolTask(this Action action) => Task.Run(action);
|
||||
|
||||
//public static Task ToThreadPoolTask(this Task task) => Task.Run(() => _ = task);
|
||||
//public static void ToParallelTaskStart(this Task task) => task.Start();
|
||||
//public static Task<T> ToThreadPoolTask<T>(this Task<T> task) => Task.Run(() => task);
|
||||
public static Task<T> ToThreadPoolTask<T>(this Func<Task<T>> func) => Task.Run(func); //TODO: Letesztelni, a ThreadId-kat! - J.
|
||||
public static Task<T> ToThreadPoolTask<T>(this Func<Task<T>> func) => Task.Run(func);
|
||||
public static Task<T> ToThreadPoolTask<T>(this Func<T> func) => Task.Run(func);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ namespace AyCode.Database.Tests.Internal
|
|||
{
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
//[TestMethod]
|
||||
public override void DatabaseExistsTest() => base.DatabaseExistsTest();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ using AyCode.Database.Tests.Users;
|
|||
|
||||
namespace AyCode.Database.Tests.Internal.Users;
|
||||
|
||||
[TestClass]
|
||||
//[TestClass]
|
||||
public sealed class UserDalTests : AcUserDalTestBase<UserDal, UserDbContext, User, Profile, UserToken, Company, UserToCompany, Address, EmailMessage>
|
||||
{
|
||||
[DataTestMethod]
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
<ProjectReference Include="..\AyCode.Models.Server\AyCode.Models.Server.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Models\AyCode.Models.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Services.Server\AyCode.Services.Server.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Services.Tests\AyCode.Services.Tests.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Services\AyCode.Services.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Utils.Server\AyCode.Utils.Server.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Utils\AyCode.Utils.csproj" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Services.Server.SignalRs;
|
||||
using AyCode.Services.Server.Tests.SignalRs;
|
||||
|
||||
namespace AyCode.Services.Server.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class InvokeMethodExtensionTests
|
||||
{
|
||||
#region InvokeMethod Unit Tests
|
||||
|
||||
[TestMethod]
|
||||
public void InvokeMethod_SyncMethod_ReturnsValue()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleSingleInt")!;
|
||||
|
||||
var result = methodInfo.InvokeMethod(service, 42);
|
||||
|
||||
Assert.AreEqual("42", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvokeMethod_AsyncTaskTMethod_ReturnsUnwrappedValue()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleAsyncString")!;
|
||||
|
||||
var result = methodInfo.InvokeMethod(service, "Test");
|
||||
|
||||
Assert.IsNotNull(result, "InvokeMethod should unwrap Task<T> and return the result");
|
||||
Assert.AreEqual("Async: Test", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvokeMethod_AsyncTaskTMethod_WithComplexObject_ReturnsUnwrappedValue()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleAsyncTestOrderItem")!;
|
||||
var input = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10m };
|
||||
|
||||
var result = methodInfo.InvokeMethod(service, input);
|
||||
|
||||
Assert.IsNotNull(result, "InvokeMethod should unwrap Task<TestOrderItem> and return the result");
|
||||
Assert.IsInstanceOfType(result, typeof(TestOrderItem));
|
||||
var item = (TestOrderItem)result;
|
||||
Assert.AreEqual("Async: Widget", item.ProductName);
|
||||
Assert.AreEqual(15, item.Quantity);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvokeMethod_AsyncTaskTMethod_WithInt_ReturnsUnwrappedValue()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleAsyncInt")!;
|
||||
|
||||
var result = methodInfo.InvokeMethod(service, 42);
|
||||
|
||||
Assert.IsNotNull(result, "InvokeMethod should unwrap Task<int> and return the result");
|
||||
Assert.AreEqual(84, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRITICAL: Tests Task.FromResult() - methods returning Task without async keyword.
|
||||
/// This was the root cause of the production bug where Task wrapper was serialized.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void InvokeMethod_TaskFromResult_ReturnsUnwrappedValue()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultString")!;
|
||||
|
||||
var result = methodInfo.InvokeMethod(service, "Test");
|
||||
|
||||
Assert.IsNotNull(result, "InvokeMethod should unwrap Task.FromResult<T> and return the result");
|
||||
Assert.AreEqual("FromResult: Test", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvokeMethod_TaskFromResult_WithComplexObject_ReturnsUnwrappedValue()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultTestOrderItem")!;
|
||||
var input = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10m };
|
||||
|
||||
var result = methodInfo.InvokeMethod(service, input);
|
||||
|
||||
Assert.IsNotNull(result, "InvokeMethod should unwrap Task.FromResult<TestOrderItem> and return the result");
|
||||
Assert.IsInstanceOfType(result, typeof(TestOrderItem));
|
||||
var item = (TestOrderItem)result;
|
||||
Assert.AreEqual("FromResult: Widget", item.ProductName);
|
||||
Assert.AreEqual(10, item.Quantity); // Doubled
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvokeMethod_TaskFromResult_WithInt_ReturnsUnwrappedValue()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultInt")!;
|
||||
|
||||
var result = methodInfo.InvokeMethod(service, 42);
|
||||
|
||||
Assert.IsNotNull(result, "InvokeMethod should unwrap Task.FromResult<int> and return the result");
|
||||
Assert.AreEqual(84, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvokeMethod_NonGenericTask_ReturnsNull()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultNoParams")!;
|
||||
|
||||
var result = methodInfo.InvokeMethod(service);
|
||||
|
||||
// Task.CompletedTask returns a completed Task, InvokeMethod waits for it
|
||||
// The result should be null since it's a non-generic Task (no return value)
|
||||
// Note: Task.CompletedTask internally may be Task<VoidTaskResult> but we don't expose it
|
||||
// The important thing is the method completes without exception
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -50,7 +50,8 @@ public class TestObservableDataSource : AcSignalRDataSource<TestDataItem, int, A
|
|||
#region Mock SignalR Client
|
||||
|
||||
/// <summary>
|
||||
/// Mock SignalR client for testing AcSignalRDataSource without actual network calls
|
||||
/// Mock SignalR client for testing AcSignalRDataSource without actual network calls.
|
||||
/// Uses the test constructor to avoid real HubConnection.
|
||||
/// </summary>
|
||||
public class MockSignalRClient : AcSignalRClientBase
|
||||
{
|
||||
|
|
@ -67,7 +68,10 @@ public class MockSignalRClient : AcSignalRClientBase
|
|||
public static int NextId() => Interlocked.Increment(ref _idCounter);
|
||||
public static void ResetIdCounter() => _idCounter = 0;
|
||||
|
||||
public MockSignalRClient() : base("http://test.local/hub", new MockLogger())
|
||||
/// <summary>
|
||||
/// Uses test constructor - no real HubConnection created.
|
||||
/// </summary>
|
||||
public MockSignalRClient() : base(new MockLogger())
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,917 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Services.Server.SignalRs;
|
||||
using AyCode.Services.SignalRs;
|
||||
using MessagePack.Resolvers;
|
||||
|
||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for SignalR client-to-hub communication.
|
||||
/// Tests the full round-trip: Client -> Server -> Service -> Response -> Client
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SignalRClientToHubTest
|
||||
{
|
||||
private TestLogger _logger = null!;
|
||||
private TestableSignalRClient2 _client = null!;
|
||||
private TestableSignalRHub2 _hub = null!;
|
||||
private TestSignalRService2 _service = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_logger = new TestLogger();
|
||||
_hub = new TestableSignalRHub2();
|
||||
_service = new TestSignalRService2();
|
||||
_client = new TestableSignalRClient2(_hub, _logger);
|
||||
_hub.RegisterService(_service, _client);
|
||||
}
|
||||
|
||||
#region Primitive Parameter Tests
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(42)]
|
||||
[DataRow(0)]
|
||||
[DataRow(-100)]
|
||||
[DataRow(int.MaxValue)]
|
||||
public async Task Post_SingleInt_ReturnsStringRepresentation(int value)
|
||||
{
|
||||
var result = await _client.PostDataAsync<int, string>(TestSignalRTags.SingleIntParam, value);
|
||||
Assert.AreEqual(value.ToString(), result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(10, 20, 30)]
|
||||
[DataRow(0, 0, 0)]
|
||||
[DataRow(-5, 10, 5)]
|
||||
public async Task Post_TwoInts_ReturnsSum(int a, int b, int expectedSum)
|
||||
{
|
||||
var result = await _client.PostAsync<int>(TestSignalRTags.TwoIntParams, [a, b]);
|
||||
Assert.AreEqual(expectedSum, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(true)]
|
||||
[DataRow(false)]
|
||||
public async Task Post_Bool_ReturnsSameValue(bool value)
|
||||
{
|
||||
var result = await _client.PostDataAsync<bool, bool>(TestSignalRTags.BoolParam, value);
|
||||
Assert.AreEqual(value, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("Hello")]
|
||||
[DataRow("")]
|
||||
[DataRow("Special chars: <>\"'&")]
|
||||
public async Task Post_String_ReturnsEcho(string text)
|
||||
{
|
||||
var result = await _client.PostDataAsync<string, string>(TestSignalRTags.StringParam, text);
|
||||
Assert.AreEqual($"Echo: {text}", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_Guid_ReturnsSameValue()
|
||||
{
|
||||
var guid = Guid.NewGuid();
|
||||
var result = await _client.PostDataAsync<Guid, Guid>(TestSignalRTags.GuidParam, guid);
|
||||
Assert.AreEqual(guid, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_GuidEmpty_ReturnsSameValue()
|
||||
{
|
||||
var result = await _client.PostDataAsync<Guid, Guid>(TestSignalRTags.GuidParam, Guid.Empty);
|
||||
Assert.AreEqual(Guid.Empty, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(TestStatus.Pending, TestStatus.Pending)]
|
||||
[DataRow(TestStatus.Processing, TestStatus.Processing)]
|
||||
[DataRow(TestStatus.Completed, TestStatus.Completed)]
|
||||
public async Task Post_Enum_ReturnsSameValue(TestStatus input, TestStatus expected)
|
||||
{
|
||||
var result = await _client.PostDataAsync<TestStatus, TestStatus>(TestSignalRTags.EnumParam, input);
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Get_NoParams_ReturnsOK()
|
||||
{
|
||||
var result = await _client.GetAllAsync<string>(TestSignalRTags.NoParams);
|
||||
Assert.AreEqual("OK", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_MultipleTypes_ReturnsConcatenated()
|
||||
{
|
||||
var result = await _client.PostAsync<string>(TestSignalRTags.MultipleTypesParams, [true, "test", 42]);
|
||||
Assert.AreEqual("True-test-42", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(123.45)]
|
||||
[DataRow(0.0)]
|
||||
[DataRow(-99.99)]
|
||||
public async Task Post_Decimal_ReturnsDoubled(double inputDouble)
|
||||
{
|
||||
var input = (decimal)inputDouble;
|
||||
var result = await _client.PostDataAsync<decimal, decimal>(TestSignalRTags.DecimalParam, input);
|
||||
Assert.AreEqual(input * 2, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_DateTime_ReturnsSameValue()
|
||||
{
|
||||
var dateTime = new DateTime(2024, 6, 15, 10, 30, 45, DateTimeKind.Utc);
|
||||
var result = await _client.PostDataAsync<DateTime, DateTime>(TestSignalRTags.DateTimeParam, dateTime);
|
||||
Assert.AreEqual(dateTime, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(3.14159)]
|
||||
[DataRow(0.0)]
|
||||
[DataRow(-1.5)]
|
||||
public async Task Post_Double_ReturnsSameValue(double value)
|
||||
{
|
||||
var result = await _client.PostDataAsync<double, double>(TestSignalRTags.DoubleParam, value);
|
||||
Assert.AreEqual(value, result, 0.0001);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(9223372036854775807L)]
|
||||
[DataRow(0L)]
|
||||
[DataRow(-1L)]
|
||||
public async Task Post_Long_ReturnsSameValue(long value)
|
||||
{
|
||||
var result = await _client.PostDataAsync<long, long>(TestSignalRTags.LongParam, value);
|
||||
Assert.AreEqual(value, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complex Object Tests
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_TestOrderItem_ReturnsProcessedItem()
|
||||
{
|
||||
var item = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10.50m };
|
||||
|
||||
var result = await _client.PostDataAsync(TestSignalRTags.TestOrderItemParam, item);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual("Processed: Widget", result.ProductName);
|
||||
Assert.AreEqual(item.Quantity * 2, result.Quantity); // Doubled
|
||||
Assert.AreEqual(item.UnitPrice * 2, result.UnitPrice);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_TestOrder_ReturnsSameOrder()
|
||||
{
|
||||
var order = TestDataFactory.CreateOrder(itemCount: 2);
|
||||
|
||||
var result = await _client.PostDataAsync<TestOrder, TestOrder>(TestSignalRTags.TestOrderParam, order);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(order.Id, result.Id);
|
||||
Assert.AreEqual(order.OrderNumber, result.OrderNumber);
|
||||
Assert.AreEqual(2, result.Items.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_SharedTag_ReturnsSameTag()
|
||||
{
|
||||
var tag = new SharedTag { Id = 1, Name = "Important", Color = "#FF0000" };
|
||||
|
||||
var result = await _client.PostDataAsync<SharedTag, SharedTag>(TestSignalRTags.SharedTagParam, tag);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual("Important", result.Name);
|
||||
Assert.AreEqual("#FF0000", result.Color);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Collection Parameter Tests (using PostDataAsync for complex types)
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_IntArray_ReturnsDoubledValues()
|
||||
{
|
||||
var input = new[] { 1, 2, 3, 4, 5 };
|
||||
|
||||
var result = await _client.PostDataAsync<int[], int[]>(TestSignalRTags.IntArrayParam, input);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
CollectionAssert.AreEqual(new[] { 2, 4, 6, 8, 10 }, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_GuidArray_ReturnsSameValues()
|
||||
{
|
||||
var guids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
||||
|
||||
var result = await _client.PostDataAsync<Guid[], Guid[]>(TestSignalRTags.GuidArrayParam, guids);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
CollectionAssert.AreEqual(guids, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_StringList_ReturnsUppercased()
|
||||
{
|
||||
var input = new List<string> { "apple", "banana", "cherry" };
|
||||
|
||||
var result = await _client.PostDataAsync<List<string>, List<string>>(TestSignalRTags.StringListParam, input);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
CollectionAssert.AreEqual(new List<string> { "APPLE", "BANANA", "CHERRY" }, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_TestOrderItemList_ReturnsSameItems()
|
||||
{
|
||||
var items = new List<TestOrderItem>
|
||||
{
|
||||
new() { Id = 1, ProductName = "Item1" },
|
||||
new() { Id = 2, ProductName = "Item2" }
|
||||
};
|
||||
|
||||
var result = await _client.PostDataAsync<List<TestOrderItem>, List<TestOrderItem>>(TestSignalRTags.TestOrderItemListParam, items);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(2, result.Count);
|
||||
Assert.AreEqual("Item1", result[0].ProductName);
|
||||
Assert.AreEqual("Item2", result[1].ProductName);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_IntList_ReturnsDoubledValues()
|
||||
{
|
||||
var input = new List<int> { 10, 20, 30 };
|
||||
|
||||
var result = await _client.PostDataAsync<List<int>, List<int>>(TestSignalRTags.IntListParam, input);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
CollectionAssert.AreEqual(new List<int> { 20, 40, 60 }, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_BoolArray_ReturnsSameValues()
|
||||
{
|
||||
var input = new[] { true, false, true, false };
|
||||
|
||||
var result = await _client.PostDataAsync<bool[], bool[]>(TestSignalRTags.BoolArrayParam, input);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
CollectionAssert.AreEqual(input, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_MixedWithArray_ReturnsFormattedString()
|
||||
{
|
||||
var numbers = new[] { 1, 2, 3 };
|
||||
|
||||
var result = await _client.PostAsync<string>(TestSignalRTags.MixedWithArrayParam, [true, numbers, "end"]);
|
||||
|
||||
Assert.AreEqual("True-[1,2,3]-end", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_NestedList_ReturnsSameStructure()
|
||||
{
|
||||
var nested = new List<List<int>>
|
||||
{
|
||||
new() { 1, 2, 3 },
|
||||
new() { 4, 5 },
|
||||
new() { 6 }
|
||||
};
|
||||
|
||||
var result = await _client.PostDataAsync<List<List<int>>, List<List<int>>>(TestSignalRTags.NestedListParam, nested);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3, result.Count);
|
||||
CollectionAssert.AreEqual(new List<int> { 1, 2, 3 }, result[0]);
|
||||
CollectionAssert.AreEqual(new List<int> { 4, 5 }, result[1]);
|
||||
CollectionAssert.AreEqual(new List<int> { 6 }, result[2]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extended Array Tests
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_LongArray_ReturnsSameValues()
|
||||
{
|
||||
var input = new[] { 1L, 2L, long.MaxValue };
|
||||
|
||||
var result = await _client.PostDataAsync<long[], long[]>(TestSignalRTags.LongArrayParam, input);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
CollectionAssert.AreEqual(input, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// REGRESSION TEST: Tests GetAllAsync with empty array as context parameter.
|
||||
/// Bug: When passing an empty array via GetAllAsync with contextParams,
|
||||
/// the server receives an object instead of an array, causing deserialization failure.
|
||||
/// Error: "JSON is an object but target type 'Int32[]' is a collection type"
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public async Task GetAll_WithEmptyArrayContextParam_ReturnsResult()
|
||||
{
|
||||
var result = await _client.GetAllAsync<int[]>(TestSignalRTags.IntArrayParam, [Array.Empty<int>()]);
|
||||
|
||||
Assert.IsNotNull(result, "Result should not be null");
|
||||
// Empty array doubled is still empty array
|
||||
Assert.AreEqual(0, result.Length, "Empty array should return empty array");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests GetAllAsync with non-empty array as context parameter.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public async Task GetAll_WithArrayContextParam_ReturnsDoubledValues()
|
||||
{
|
||||
var input = new[] { 1, 2, 3 };
|
||||
var result = await _client.GetAllAsync<int[]>(TestSignalRTags.IntArrayParam, [input]);
|
||||
|
||||
Assert.IsNotNull(result, "Result should not be null");
|
||||
CollectionAssert.AreEqual(new[] { 2, 4, 6 }, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_DecimalArray_ReturnsSameValues()
|
||||
{
|
||||
var input = new[] { 1.1m, 2.2m, 3.3m };
|
||||
|
||||
var result = await _client.PostDataAsync<decimal[], decimal[]>(TestSignalRTags.DecimalArrayParam, input);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
CollectionAssert.AreEqual(input, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_DateTimeArray_ReturnsSameValues()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2024, 6, 15, 12, 30, 0, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
var result = await _client.PostDataAsync<DateTime[], DateTime[]>(TestSignalRTags.DateTimeArrayParam, input);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(2, result.Length);
|
||||
Assert.AreEqual(input[0], result[0]);
|
||||
Assert.AreEqual(input[1], result[1]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_EnumArray_ReturnsSameValues()
|
||||
{
|
||||
var input = new[] { TestStatus.Pending, TestStatus.Completed, TestStatus.Processing };
|
||||
|
||||
var result = await _client.PostDataAsync<TestStatus[], TestStatus[]>(TestSignalRTags.EnumArrayParam, input);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
CollectionAssert.AreEqual(input, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_DoubleArray_ReturnsSameValues()
|
||||
{
|
||||
var input = new[] { 1.1, 2.2, 3.3 };
|
||||
|
||||
var result = await _client.PostDataAsync<double[], double[]>(TestSignalRTags.DoubleArrayParam, input);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3, result.Length);
|
||||
Assert.AreEqual(1.1, result[0], 0.001);
|
||||
Assert.AreEqual(2.2, result[1], 0.001);
|
||||
Assert.AreEqual(3.3, result[2], 0.001);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_SharedTagArray_ReturnsSameValues()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new SharedTag { Id = 1, Name = "Tag1", Color = "#FF0000" },
|
||||
new SharedTag { Id = 2, Name = "Tag2", Color = "#00FF00" }
|
||||
};
|
||||
|
||||
var result = await _client.PostDataAsync<SharedTag[], SharedTag[]>(TestSignalRTags.SharedTagArrayParam, input);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(2, result.Length);
|
||||
Assert.AreEqual("Tag1", result[0].Name);
|
||||
Assert.AreEqual("Tag2", result[1].Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_Dictionary_ReturnsSameValues()
|
||||
{
|
||||
var input = new Dictionary<string, int>
|
||||
{
|
||||
{ "one", 1 },
|
||||
{ "two", 2 },
|
||||
{ "three", 3 }
|
||||
};
|
||||
|
||||
var result = await _client.PostDataAsync<Dictionary<string, int>, Dictionary<string, int>>(TestSignalRTags.DictionaryParam, input);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3, result.Count);
|
||||
Assert.AreEqual(1, result["one"]);
|
||||
Assert.AreEqual(2, result["two"]);
|
||||
Assert.AreEqual(3, result["three"]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mixed Parameter Tests
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_IntAndDto_ReturnsFormattedString()
|
||||
{
|
||||
var item = new TestOrderItem { Id = 1, ProductName = "Widget" };
|
||||
|
||||
var result = await _client.PostAsync<string>(TestSignalRTags.IntAndDtoParam, [42, item]);
|
||||
|
||||
Assert.AreEqual("42-Widget", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_DtoAndList_ReturnsFormattedString()
|
||||
{
|
||||
var item = new TestOrderItem { Id = 1, ProductName = "Product" };
|
||||
var numbers = new List<int> { 1, 2, 3 };
|
||||
|
||||
var result = await _client.PostAsync<string>(TestSignalRTags.DtoAndListParam, [item, numbers]);
|
||||
|
||||
Assert.AreEqual("Product-[1,2,3]", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_ThreeComplexParams_ReturnsFormattedString()
|
||||
{
|
||||
var item = new TestOrderItem { Id = 1, ProductName = "Item" };
|
||||
var tags = new List<string> { "tag1", "tag2", "tag3" };
|
||||
var sharedTag = new SharedTag { Id = 1, Name = "Shared" };
|
||||
|
||||
var result = await _client.PostAsync<string>(TestSignalRTags.ThreeComplexParams, [item, tags, sharedTag]);
|
||||
|
||||
Assert.AreEqual("Item-3-Shared", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_FiveParams_ReturnsFormattedString()
|
||||
{
|
||||
var guid = Guid.Parse("12345678-1234-1234-1234-123456789abc");
|
||||
|
||||
var result = await _client.PostAsync<string>(TestSignalRTags.FiveParams, [42, "hello", true, guid, 99.99m]);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual($"42-hello-True-{guid}-99.99", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetByIdAsync_FiveParams_ReturnsFormattedString()
|
||||
{
|
||||
var guid = Guid.NewGuid();
|
||||
|
||||
var result = await _client.GetByIdAsync<string>(TestSignalRTags.FiveParams, [1, "text", true, guid, 99.99m]);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual($"1-text-True-{guid}-99.99", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[TestMethod]
|
||||
public async Task Post_ThrowsException_ReturnsError()
|
||||
{
|
||||
var result = await _client.GetAllAsync<string>(TestSignalRTags.ThrowsException);
|
||||
|
||||
Assert.IsNull(result);
|
||||
Assert.IsTrue(_logger.HasErrorLogs);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Async Task<T> Method Tests - Critical for detecting non-awaited Tasks
|
||||
|
||||
[TestMethod]
|
||||
public async Task Async_TestOrderItem_ReturnsProcessedItem()
|
||||
{
|
||||
var item = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10.50m };
|
||||
|
||||
var result = await _client.PostDataAsync(TestSignalRTags.AsyncTestOrderItemParam, item);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual("Async: Widget", result.ProductName);
|
||||
Assert.AreEqual(item.Quantity * 3, result.Quantity);
|
||||
Assert.AreEqual(item.UnitPrice * 3, result.UnitPrice);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Async_String_ReturnsProcessedString()
|
||||
{
|
||||
var result = await _client.PostDataAsync<string, string>(TestSignalRTags.AsyncStringParam, "TestInput");
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("Async: TestInput", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Async_NoParams_ReturnsAsyncOK()
|
||||
{
|
||||
var result = await _client.GetAllAsync<string>(TestSignalRTags.AsyncNoParams);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("AsyncOK", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Async_Int_ReturnsDoubledValue()
|
||||
{
|
||||
var result = await _client.PostDataAsync<int, int>(TestSignalRTags.AsyncIntParam, 42);
|
||||
|
||||
Assert.AreEqual(84, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MessagePack Round-Trip Integrity Tests
|
||||
|
||||
[TestMethod]
|
||||
public async Task MessagePack_ComplexObject_PreservesAllProperties()
|
||||
{
|
||||
// Test that complex objects survive the full MessagePack round-trip
|
||||
var item = new TestOrderItem
|
||||
{
|
||||
Id = 999,
|
||||
ProductName = "RoundTrip Test Item",
|
||||
Quantity = 50,
|
||||
UnitPrice = 123.45m,
|
||||
Status = TestStatus.Processing
|
||||
};
|
||||
|
||||
var result = await _client.PostDataAsync(TestSignalRTags.TestOrderItemParam, item);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(999, result.Id);
|
||||
Assert.AreEqual("Processed: RoundTrip Test Item", result.ProductName);
|
||||
Assert.AreEqual(100, result.Quantity); // Doubled by service
|
||||
Assert.AreEqual(246.90m, result.UnitPrice); // Doubled by service
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MessagePack_NestedOrder_PreservesHierarchy()
|
||||
{
|
||||
// Test deeply nested object structure survives round-trip
|
||||
var order = TestDataFactory.CreateOrder(itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 1);
|
||||
|
||||
var result = await _client.PostDataAsync<TestOrder, TestOrder>(TestSignalRTags.TestOrderParam, order);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(order.Id, result.Id);
|
||||
Assert.AreEqual(order.OrderNumber, result.OrderNumber);
|
||||
Assert.AreEqual(3, result.Items.Count);
|
||||
|
||||
// Verify nested items preserved
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
Assert.AreEqual(order.Items[i].Id, result.Items[i].Id);
|
||||
Assert.AreEqual(2, result.Items[i].Pallets.Count);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MessagePack_SpecialCharacters_PreservedCorrectly()
|
||||
{
|
||||
// Test that special characters survive JSON serialization in MessagePack payload
|
||||
var testString = "Special: \"quotes\" 'apostrophes' <brackets> & ampersand \\ backslash \n newline";
|
||||
|
||||
var result = await _client.PostDataAsync<string, string>(TestSignalRTags.StringParam, testString);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual($"Echo: {testString}", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MessagePack_UnicodeCharacters_PreservedCorrectly()
|
||||
{
|
||||
// Test that Unicode characters survive the round-trip
|
||||
var testString = "Unicode: 中文 日本語 한국어 🎉 émoji";
|
||||
|
||||
var result = await _client.PostDataAsync<string, string>(TestSignalRTags.StringParam, testString);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual($"Echo: {testString}", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MessagePack_EmptyString_PreservedCorrectly()
|
||||
{
|
||||
var result = await _client.PostDataAsync<string, string>(TestSignalRTags.StringParam, "");
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("Echo: ", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MessagePack_LargeDecimal_PreservedCorrectly()
|
||||
{
|
||||
var largeDecimal = 999999999999.999999m;
|
||||
|
||||
var result = await _client.PostDataAsync<decimal, decimal>(TestSignalRTags.DecimalParam, largeDecimal);
|
||||
|
||||
Assert.AreEqual(largeDecimal * 2, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MessagePack_ExtremeInt_PreservedCorrectly()
|
||||
{
|
||||
var result = await _client.PostDataAsync<int, string>(TestSignalRTags.SingleIntParam, int.MaxValue);
|
||||
|
||||
Assert.AreEqual(int.MaxValue.ToString(), result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Serialization Integrity Tests
|
||||
|
||||
[TestMethod]
|
||||
public async Task Json_ResponseData_NotDoubleEscaped()
|
||||
{
|
||||
// This test ensures the JSON in ResponseData is not double-escaped
|
||||
// which was the original bug we fixed
|
||||
var item = new TestOrderItem { Id = 1, ProductName = "Test", Quantity = 10, UnitPrice = 20m };
|
||||
|
||||
var result = await _client.PostDataAsync(TestSignalRTags.TestOrderItemParam, item);
|
||||
|
||||
// If double serialization occurred, result would be null or have wrong values
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual("Processed: Test", result.ProductName);
|
||||
|
||||
// Verify numeric values survived correctly (would fail with double-escaped JSON)
|
||||
Assert.AreEqual(20, result.Quantity);
|
||||
Assert.AreEqual(40m, result.UnitPrice);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Json_CollectionResponse_DeserializesCorrectly()
|
||||
{
|
||||
var items = new List<TestOrderItem>
|
||||
{
|
||||
new() { Id = 1, ProductName = "Item1", Quantity = 10, UnitPrice = 1.1m },
|
||||
new() { Id = 2, ProductName = "Item2", Quantity = 20, UnitPrice = 2.2m },
|
||||
new() { Id = 3, ProductName = "Item3", Quantity = 30, UnitPrice = 3.3m }
|
||||
};
|
||||
|
||||
var result = await _client.PostDataAsync<List<TestOrderItem>, List<TestOrderItem>>(
|
||||
TestSignalRTags.TestOrderItemListParam, items);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3, result.Count);
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
Assert.AreEqual(items[i].Id, result[i].Id);
|
||||
Assert.AreEqual(items[i].ProductName, result[i].ProductName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRITICAL TEST: Ensures async Task<T> methods are properly awaited before serialization.
|
||||
///
|
||||
/// Bug scenario: If an async method returns Task<TestOrderItem> and the framework
|
||||
/// serializes the Task object instead of awaiting it, the JSON looks like:
|
||||
/// {"Result":{"Id":1,"ProductName":"..."},"Status":5,"IsCompleted":true,"IsCompletedSuccessfully":true}
|
||||
///
|
||||
/// The actual data is wrapped in "Result" property, and Task metadata pollutes the response.
|
||||
/// This test ensures we get the actual object, not the Task wrapper.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public async Task Async_Method_ReturnsActualResult_NotTaskWrapper()
|
||||
{
|
||||
var item = new TestOrderItem { Id = 42, ProductName = "TestProduct", Quantity = 5, UnitPrice = 10m };
|
||||
|
||||
var result = await _client.PostDataAsync(TestSignalRTags.AsyncTestOrderItemParam, item);
|
||||
|
||||
// If Task was serialized instead of awaited, result would have wrong values:
|
||||
// - Id would still be correct (Task.Id coincidentally matches)
|
||||
// - ProductName would be null/empty (not at root level)
|
||||
// - Quantity would be 0 (not at root level)
|
||||
Assert.IsNotNull(result, "Result should not be null");
|
||||
Assert.AreEqual(42, result.Id, "Id should match the original");
|
||||
Assert.AreEqual("Async: TestProduct", result.ProductName,
|
||||
"ProductName should be 'Async: TestProduct', not null. " +
|
||||
"If null, the async method result was not properly awaited and Task was serialized.");
|
||||
Assert.AreEqual(15, result.Quantity,
|
||||
"Quantity should be tripled (5*3=15), not 0. " +
|
||||
"If 0, the async method result was not properly awaited.");
|
||||
Assert.AreEqual(30m, result.UnitPrice,
|
||||
"UnitPrice should be tripled (10*3=30), not 0. " +
|
||||
"If 0, the async method result was not properly awaited.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Task.FromResult Integration Tests - CRITICAL for production bug coverage
|
||||
|
||||
/// <summary>
|
||||
/// CRITICAL TEST: Tests full SignalR round-trip with Task.FromResult<string>.
|
||||
/// This was the root cause of the production bug where methods returning Task
|
||||
/// without async keyword caused Task wrapper to be serialized.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public async Task TaskFromResult_String_ReturnsActualResult_NotTaskWrapper()
|
||||
{
|
||||
var result = await _client.PostDataAsync<string, string>(TestSignalRTags.TaskFromResultStringParam, "TestInput");
|
||||
|
||||
Assert.IsNotNull(result, "Result should not be null");
|
||||
Assert.AreEqual("FromResult: TestInput", result,
|
||||
"Should return the actual string, not Task wrapper JSON");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRITICAL TEST: Tests full SignalR round-trip with Task.FromResult<TestOrderItem>.
|
||||
/// This simulates the exact production bug scenario where complex objects returned
|
||||
/// via Task.FromResult were serialized as Task wrapper instead of the actual object.
|
||||
///
|
||||
/// Bug JSON output was:
|
||||
/// {"Result":{"Id":1,"ProductName":"..."},"Status":5,"IsCompleted":true,"IsCompletedSuccessfully":true}
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public async Task TaskFromResult_ComplexObject_ReturnsActualResult_NotTaskWrapper()
|
||||
{
|
||||
var item = new TestOrderItem { Id = 42, ProductName = "TestProduct", Quantity = 5, UnitPrice = 10m };
|
||||
|
||||
var result = await _client.PostDataAsync(TestSignalRTags.TaskFromResultTestOrderItemParam, item);
|
||||
|
||||
// If Task wrapper was serialized, these assertions would fail:
|
||||
// - ProductName would be null (nested inside "Result" property)
|
||||
// - Quantity would be 0
|
||||
// - UnitPrice would be 0
|
||||
Assert.IsNotNull(result, "Result should not be null");
|
||||
Assert.AreEqual(42, result.Id, "Id should match the original");
|
||||
Assert.AreEqual("FromResult: TestProduct", result.ProductName,
|
||||
"ProductName should be 'FromResult: TestProduct'. " +
|
||||
"If null, Task.FromResult result was not properly unwrapped.");
|
||||
Assert.AreEqual(10, result.Quantity,
|
||||
"Quantity should be doubled (5*2=10). " +
|
||||
"If 0, Task.FromResult result was not properly unwrapped.");
|
||||
Assert.AreEqual(20m, result.UnitPrice,
|
||||
"UnitPrice should be doubled (10*2=20). " +
|
||||
"If 0, Task.FromResult result was not properly unwrapped.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests Task.FromResult<int> - primitive value type via Task.FromResult.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public async Task TaskFromResult_Int_ReturnsActualResult_NotTaskWrapper()
|
||||
{
|
||||
var result = await _client.PostDataAsync<int, int>(TestSignalRTags.TaskFromResultIntParam, 42);
|
||||
|
||||
Assert.AreEqual(84, result,
|
||||
"Should return doubled value (42*2=84). " +
|
||||
"If 0 or wrong value, Task.FromResult<int> was not properly unwrapped.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests Task.CompletedTask (non-generic Task) via SignalR.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public async Task TaskFromResult_NoResult_CompletesSuccessfully()
|
||||
{
|
||||
// This should not throw and should complete successfully
|
||||
var result = await _client.GetAllAsync<object>(TestSignalRTags.TaskFromResultNoParams);
|
||||
|
||||
// Non-generic Task returns null as there's no result value
|
||||
// The important thing is the call completes without error
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvokeMethod Unit Tests
|
||||
|
||||
[TestMethod]
|
||||
public void InvokeMethod_SyncMethod_ReturnsValue()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleSingleInt")!
|
||||
;
|
||||
var result = methodInfo.InvokeMethod(service, 42);
|
||||
|
||||
Assert.AreEqual("42", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvokeMethod_AsyncTaskTMethod_ReturnsUnwrappedValue()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleAsyncString")!
|
||||
;
|
||||
|
||||
var result = methodInfo.InvokeMethod(service, "Test");
|
||||
|
||||
Assert.IsNotNull(result, "InvokeMethod should unwrap Task<T> and return the result");
|
||||
Assert.AreEqual("Async: Test", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvokeMethod_AsyncTaskTMethod_WithComplexObject_ReturnsUnwrappedValue()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleAsyncTestOrderItem")!;
|
||||
var input = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10m };
|
||||
|
||||
var result = methodInfo.InvokeMethod(service, input);
|
||||
|
||||
Assert.IsNotNull(result, "InvokeMethod should unwrap Task<TestOrderItem> and return the result");
|
||||
Assert.IsInstanceOfType(result, typeof(TestOrderItem));
|
||||
var item = (TestOrderItem)result;
|
||||
Assert.AreEqual("Async: Widget", item.ProductName);
|
||||
Assert.AreEqual(15, item.Quantity);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvokeMethod_AsyncTaskTMethod_WithInt_ReturnsUnwrappedValue()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleAsyncInt")!;
|
||||
|
||||
var result = methodInfo.InvokeMethod(service, 42);
|
||||
|
||||
Assert.IsNotNull(result, "InvokeMethod should unwrap Task<int> and return the result");
|
||||
Assert.AreEqual(84, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRITICAL: Tests Task.FromResult() - methods returning Task without async keyword.
|
||||
/// This was the root cause of the production bug where Task wrapper was serialized.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void InvokeMethod_TaskFromResult_ReturnsUnwrappedValue()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultString")!;
|
||||
|
||||
var result = methodInfo.InvokeMethod(service, "Test");
|
||||
|
||||
Assert.IsNotNull(result, "InvokeMethod should unwrap Task.FromResult<T> and return the result");
|
||||
Assert.AreEqual("FromResult: Test", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvokeMethod_TaskFromResult_WithComplexObject_ReturnsUnwrappedValue()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultTestOrderItem")!;
|
||||
var input = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10m };
|
||||
|
||||
var result = methodInfo.InvokeMethod(service, input);
|
||||
|
||||
Assert.IsNotNull(result, "InvokeMethod should unwrap Task.FromResult<TestOrderItem> and return the result");
|
||||
Assert.IsInstanceOfType(result, typeof(TestOrderItem));
|
||||
var item = (TestOrderItem)result;
|
||||
Assert.AreEqual("FromResult: Widget", item.ProductName);
|
||||
Assert.AreEqual(10, item.Quantity); // Doubled
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvokeMethod_TaskFromResult_WithInt_ReturnsUnwrappedValue()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultInt")!;
|
||||
|
||||
var result = methodInfo.InvokeMethod(service, 42);
|
||||
|
||||
Assert.IsNotNull(result, "InvokeMethod should unwrap Task.FromResult<int> and return the result");
|
||||
Assert.AreEqual(84, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvokeMethod_NonGenericTask_CompletesWithoutError()
|
||||
{
|
||||
var service = new TestSignalRService2();
|
||||
var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultNoParams")!;
|
||||
|
||||
// Should complete without throwing
|
||||
var result = methodInfo.InvokeMethod(service);
|
||||
|
||||
// Task.CompletedTask may return internal VoidTaskResult, the important thing is no exception
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Services.SignalRs;
|
||||
using MessagePack;
|
||||
using MessagePack.Resolvers;
|
||||
|
||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for creating SignalR test messages.
|
||||
/// Uses the production SignalR types for compatibility with the actual client/server code.
|
||||
/// </summary>
|
||||
public static class SignalRTestHelper
|
||||
{
|
||||
private static readonly MessagePackSerializerOptions MessagePackOptions = ContractlessStandardResolver.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MessagePack message for parameters using IdMessage format.
|
||||
/// Each parameter is serialized directly as JSON (no array wrapping).
|
||||
/// </summary>
|
||||
public static byte[] CreatePrimitiveParamsMessage(params object[] values)
|
||||
{
|
||||
var idMessage = new IdMessage(values);
|
||||
var postMessage = new SignalPostJsonDataMessage<IdMessage>(idMessage);
|
||||
return MessagePackSerializer.Serialize(postMessage, MessagePackOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MessagePack message for a single primitive parameter.
|
||||
/// </summary>
|
||||
public static byte[] CreateSinglePrimitiveMessage<T>(T value) where T : notnull
|
||||
{
|
||||
return CreatePrimitiveParamsMessage(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MessagePack message for a complex object parameter.
|
||||
/// Uses PostDataJson pattern for single complex objects.
|
||||
/// </summary>
|
||||
public static byte[] CreateComplexObjectMessage<T>(T obj)
|
||||
{
|
||||
var json = obj.ToJson();
|
||||
var postMessage = new SignalPostJsonDataMessage<object>(json);
|
||||
return MessagePackSerializer.Serialize(postMessage, MessagePackSerializerOptions.Standard);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty MessagePack message for parameterless methods.
|
||||
/// </summary>
|
||||
public static byte[] CreateEmptyMessage()
|
||||
{
|
||||
var postMessage = new SignalPostJsonDataMessage<object>();
|
||||
return MessagePackSerializer.Serialize(postMessage, MessagePackOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize a SignalResponseJsonMessage from the captured SentMessage.
|
||||
/// </summary>
|
||||
public static T? GetResponseData<T>(SentMessage sentMessage)
|
||||
{
|
||||
if (sentMessage.AsJsonResponse?.ResponseData == null)
|
||||
return default;
|
||||
|
||||
return sentMessage.AsJsonResponse.ResponseData.JsonTo<T>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that a response was successful.
|
||||
/// </summary>
|
||||
public static void AssertSuccessResponse(SentMessage sentMessage, int expectedTag)
|
||||
{
|
||||
var response = sentMessage.AsJsonResponse;
|
||||
if (response == null)
|
||||
throw new AssertFailedException("Response is not a SignalResponseJsonMessage");
|
||||
|
||||
if (response.Status != SignalResponseStatus.Success)
|
||||
throw new AssertFailedException($"Expected Success status but got {response.Status}");
|
||||
|
||||
if (sentMessage.MessageTag != expectedTag)
|
||||
throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that a response was an error.
|
||||
/// </summary>
|
||||
public static void AssertErrorResponse(SentMessage sentMessage, int expectedTag)
|
||||
{
|
||||
var response = sentMessage.AsJsonResponse;
|
||||
if (response == null)
|
||||
throw new AssertFailedException("Response is not a SignalResponseJsonMessage");
|
||||
|
||||
if (response.Status != SignalResponseStatus.Error)
|
||||
throw new AssertFailedException($"Expected Error status but got {response.Status}");
|
||||
|
||||
if (sentMessage.MessageTag != expectedTag)
|
||||
throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,518 @@
|
|||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Services.SignalRs;
|
||||
|
||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Test service with SignalR-attributed methods for testing ProcessOnReceiveMessage.
|
||||
/// Uses shared DTOs from AyCode.Core.Tests.TestModels.
|
||||
/// </summary>
|
||||
public class TestSignalRService
|
||||
{
|
||||
#region Captured Values for Assertions
|
||||
|
||||
// Primitive captures
|
||||
public bool SingleIntMethodCalled { get; private set; }
|
||||
public int? ReceivedInt { get; private set; }
|
||||
|
||||
public bool TwoIntMethodCalled { get; private set; }
|
||||
public (int A, int B)? ReceivedTwoInts { get; private set; }
|
||||
|
||||
public bool BoolMethodCalled { get; private set; }
|
||||
public bool? ReceivedBool { get; private set; }
|
||||
|
||||
public bool StringMethodCalled { get; private set; }
|
||||
public string? ReceivedString { get; private set; }
|
||||
|
||||
public bool GuidMethodCalled { get; private set; }
|
||||
public Guid? ReceivedGuid { get; private set; }
|
||||
|
||||
public bool EnumMethodCalled { get; private set; }
|
||||
public TestStatus? ReceivedEnum { get; private set; }
|
||||
|
||||
public bool NoParamsMethodCalled { get; private set; }
|
||||
|
||||
public bool MultipleTypesMethodCalled { get; private set; }
|
||||
public (bool, string, int)? ReceivedMultipleTypes { get; private set; }
|
||||
|
||||
// Extended primitives
|
||||
public bool DecimalMethodCalled { get; private set; }
|
||||
public decimal? ReceivedDecimal { get; private set; }
|
||||
|
||||
public bool DateTimeMethodCalled { get; private set; }
|
||||
public DateTime? ReceivedDateTime { get; private set; }
|
||||
|
||||
public bool DoubleMethodCalled { get; private set; }
|
||||
public double? ReceivedDouble { get; private set; }
|
||||
|
||||
public bool LongMethodCalled { get; private set; }
|
||||
public long? ReceivedLong { get; private set; }
|
||||
|
||||
// Complex object captures (using shared DTOs)
|
||||
public bool TestOrderItemMethodCalled { get; private set; }
|
||||
public TestOrderItem? ReceivedTestOrderItem { get; private set; }
|
||||
|
||||
public bool TestOrderMethodCalled { get; private set; }
|
||||
public TestOrder? ReceivedTestOrder { get; private set; }
|
||||
|
||||
public bool SharedTagMethodCalled { get; private set; }
|
||||
public SharedTag? ReceivedSharedTag { get; private set; }
|
||||
|
||||
// Collection captures
|
||||
public bool IntArrayMethodCalled { get; private set; }
|
||||
public int[]? ReceivedIntArray { get; private set; }
|
||||
|
||||
public bool GuidArrayMethodCalled { get; private set; }
|
||||
public Guid[]? ReceivedGuidArray { get; private set; }
|
||||
|
||||
public bool StringListMethodCalled { get; private set; }
|
||||
public List<string>? ReceivedStringList { get; private set; }
|
||||
|
||||
public bool TestOrderItemListMethodCalled { get; private set; }
|
||||
public List<TestOrderItem>? ReceivedTestOrderItemList { get; private set; }
|
||||
|
||||
public bool IntListMethodCalled { get; private set; }
|
||||
public List<int>? ReceivedIntList { get; private set; }
|
||||
|
||||
public bool BoolArrayMethodCalled { get; private set; }
|
||||
public bool[]? ReceivedBoolArray { get; private set; }
|
||||
|
||||
public bool MixedWithArrayMethodCalled { get; private set; }
|
||||
public (bool, int[], string)? ReceivedMixedWithArray { get; private set; }
|
||||
|
||||
public bool NestedListMethodCalled { get; private set; }
|
||||
public List<List<int>>? ReceivedNestedList { get; private set; }
|
||||
|
||||
// Extended array captures for comprehensive testing
|
||||
public bool LongArrayMethodCalled { get; private set; }
|
||||
public long[]? ReceivedLongArray { get; private set; }
|
||||
|
||||
public bool DecimalArrayMethodCalled { get; private set; }
|
||||
public decimal[]? ReceivedDecimalArray { get; private set; }
|
||||
|
||||
public bool DateTimeArrayMethodCalled { get; private set; }
|
||||
public DateTime[]? ReceivedDateTimeArray { get; private set; }
|
||||
|
||||
public bool EnumArrayMethodCalled { get; private set; }
|
||||
public TestStatus[]? ReceivedEnumArray { get; private set; }
|
||||
|
||||
public bool DoubleArrayMethodCalled { get; private set; }
|
||||
public double[]? ReceivedDoubleArray { get; private set; }
|
||||
|
||||
public bool SharedTagArrayMethodCalled { get; private set; }
|
||||
public SharedTag[]? ReceivedSharedTagArray { get; private set; }
|
||||
|
||||
public bool DictionaryMethodCalled { get; private set; }
|
||||
public Dictionary<string, int>? ReceivedDictionary { get; private set; }
|
||||
|
||||
public bool ObjectArrayMethodCalled { get; private set; }
|
||||
public object[]? ReceivedObjectArray { get; private set; }
|
||||
|
||||
// Mixed parameter captures
|
||||
public bool IntAndDtoMethodCalled { get; private set; }
|
||||
public (int, TestOrderItem?)? ReceivedIntAndDto { get; private set; }
|
||||
|
||||
public bool DtoAndListMethodCalled { get; private set; }
|
||||
public (TestOrderItem?, List<int>?)? ReceivedDtoAndList { get; private set; }
|
||||
|
||||
public bool ThreeComplexParamsMethodCalled { get; private set; }
|
||||
public (TestOrderItem?, List<string>?, SharedTag?)? ReceivedThreeComplexParams { get; private set; }
|
||||
|
||||
public bool FiveParamsMethodCalled { get; private set; }
|
||||
public (int, string?, bool, Guid, decimal)? ReceivedFiveParams { get; private set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Primitive Parameter Handlers
|
||||
|
||||
[SignalR(TestSignalRTags.SingleIntParam)]
|
||||
public string HandleSingleInt(int value)
|
||||
{
|
||||
SingleIntMethodCalled = true;
|
||||
ReceivedInt = value;
|
||||
return $"Received: {value}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.TwoIntParams)]
|
||||
public int HandleTwoInts(int a, int b)
|
||||
{
|
||||
TwoIntMethodCalled = true;
|
||||
ReceivedTwoInts = (a, b);
|
||||
return a + b;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.BoolParam)]
|
||||
public bool HandleBool(bool loadRelations)
|
||||
{
|
||||
BoolMethodCalled = true;
|
||||
ReceivedBool = loadRelations;
|
||||
return loadRelations;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.StringParam)]
|
||||
public string HandleString(string text)
|
||||
{
|
||||
StringMethodCalled = true;
|
||||
ReceivedString = text;
|
||||
return $"Echo: {text}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.GuidParam)]
|
||||
public Guid HandleGuid(Guid id)
|
||||
{
|
||||
GuidMethodCalled = true;
|
||||
ReceivedGuid = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.EnumParam)]
|
||||
public TestStatus HandleEnum(TestStatus status)
|
||||
{
|
||||
EnumMethodCalled = true;
|
||||
ReceivedEnum = status;
|
||||
return status;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.NoParams)]
|
||||
public string HandleNoParams()
|
||||
{
|
||||
NoParamsMethodCalled = true;
|
||||
return "OK";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.MultipleTypesParams)]
|
||||
public string HandleMultipleTypes(bool flag, string text, int number)
|
||||
{
|
||||
MultipleTypesMethodCalled = true;
|
||||
ReceivedMultipleTypes = (flag, text, number);
|
||||
return $"{flag}-{text}-{number}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.ThrowsException)]
|
||||
public void HandleThrowsException()
|
||||
{
|
||||
throw new InvalidOperationException("Test exception");
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DecimalParam)]
|
||||
public decimal HandleDecimal(decimal value)
|
||||
{
|
||||
DecimalMethodCalled = true;
|
||||
ReceivedDecimal = value;
|
||||
return value * 2;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DateTimeParam)]
|
||||
public DateTime HandleDateTime(DateTime dateTime)
|
||||
{
|
||||
DateTimeMethodCalled = true;
|
||||
ReceivedDateTime = dateTime;
|
||||
return dateTime;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DoubleParam)]
|
||||
public double HandleDouble(double value)
|
||||
{
|
||||
DoubleMethodCalled = true;
|
||||
ReceivedDouble = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.LongParam)]
|
||||
public long HandleLong(long value)
|
||||
{
|
||||
LongMethodCalled = true;
|
||||
ReceivedLong = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complex Object Handlers (using shared DTOs)
|
||||
|
||||
[SignalR(TestSignalRTags.TestOrderItemParam)]
|
||||
public TestOrderItem HandleTestOrderItem(TestOrderItem item)
|
||||
{
|
||||
TestOrderItemMethodCalled = true;
|
||||
ReceivedTestOrderItem = item;
|
||||
return new TestOrderItem
|
||||
{
|
||||
Id = item.Id,
|
||||
ProductName = $"Processed: {item.ProductName}",
|
||||
Quantity = item.Quantity * 2,
|
||||
UnitPrice = item.UnitPrice
|
||||
};
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.TestOrderParam)]
|
||||
public TestOrder HandleTestOrder(TestOrder order)
|
||||
{
|
||||
TestOrderMethodCalled = true;
|
||||
ReceivedTestOrder = order;
|
||||
return order;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.SharedTagParam)]
|
||||
public SharedTag HandleSharedTag(SharedTag tag)
|
||||
{
|
||||
SharedTagMethodCalled = true;
|
||||
ReceivedSharedTag = tag;
|
||||
return tag;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Collection Parameter Handlers
|
||||
|
||||
[SignalR(TestSignalRTags.IntArrayParam)]
|
||||
public int[] HandleIntArray(int[] values)
|
||||
{
|
||||
IntArrayMethodCalled = true;
|
||||
ReceivedIntArray = values;
|
||||
return values.Select(x => x * 2).ToArray();
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.GuidArrayParam)]
|
||||
public Guid[] HandleGuidArray(Guid[] ids)
|
||||
{
|
||||
GuidArrayMethodCalled = true;
|
||||
ReceivedGuidArray = ids;
|
||||
return ids;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.StringListParam)]
|
||||
public List<string> HandleStringList(List<string> items)
|
||||
{
|
||||
StringListMethodCalled = true;
|
||||
ReceivedStringList = items;
|
||||
return items.Select(x => x.ToUpper()).ToList();
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.TestOrderItemListParam)]
|
||||
public List<TestOrderItem> HandleTestOrderItemList(List<TestOrderItem> items)
|
||||
{
|
||||
TestOrderItemListMethodCalled = true;
|
||||
ReceivedTestOrderItemList = items;
|
||||
return items;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.IntListParam)]
|
||||
public List<int> HandleIntList(List<int> numbers)
|
||||
{
|
||||
IntListMethodCalled = true;
|
||||
ReceivedIntList = numbers;
|
||||
return numbers.Select(x => x * 2).ToList();
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.BoolArrayParam)]
|
||||
public bool[] HandleBoolArray(bool[] flags)
|
||||
{
|
||||
BoolArrayMethodCalled = true;
|
||||
ReceivedBoolArray = flags;
|
||||
return flags;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.MixedWithArrayParam)]
|
||||
public string HandleMixedWithArray(bool flag, int[] numbers, string text)
|
||||
{
|
||||
MixedWithArrayMethodCalled = true;
|
||||
ReceivedMixedWithArray = (flag, numbers, text);
|
||||
return $"{flag}-[{string.Join(",", numbers)}]-{text}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.NestedListParam)]
|
||||
public List<List<int>> HandleNestedList(List<List<int>> nestedList)
|
||||
{
|
||||
NestedListMethodCalled = true;
|
||||
ReceivedNestedList = nestedList;
|
||||
return nestedList;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extended Array Parameter Handlers
|
||||
|
||||
[SignalR(TestSignalRTags.LongArrayParam)]
|
||||
public long[] HandleLongArray(long[] values)
|
||||
{
|
||||
LongArrayMethodCalled = true;
|
||||
ReceivedLongArray = values;
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DecimalArrayParam)]
|
||||
public decimal[] HandleDecimalArray(decimal[] values)
|
||||
{
|
||||
DecimalArrayMethodCalled = true;
|
||||
ReceivedDecimalArray = values;
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DateTimeArrayParam)]
|
||||
public DateTime[] HandleDateTimeArray(DateTime[] values)
|
||||
{
|
||||
DateTimeArrayMethodCalled = true;
|
||||
ReceivedDateTimeArray = values;
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.EnumArrayParam)]
|
||||
public TestStatus[] HandleEnumArray(TestStatus[] values)
|
||||
{
|
||||
EnumArrayMethodCalled = true;
|
||||
ReceivedEnumArray = values;
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DoubleArrayParam)]
|
||||
public double[] HandleDoubleArray(double[] values)
|
||||
{
|
||||
DoubleArrayMethodCalled = true;
|
||||
ReceivedDoubleArray = values;
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.SharedTagArrayParam)]
|
||||
public SharedTag[] HandleSharedTagArray(SharedTag[] tags)
|
||||
{
|
||||
SharedTagArrayMethodCalled = true;
|
||||
ReceivedSharedTagArray = tags;
|
||||
return tags;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DictionaryParam)]
|
||||
public Dictionary<string, int> HandleDictionary(Dictionary<string, int> dict)
|
||||
{
|
||||
DictionaryMethodCalled = true;
|
||||
ReceivedDictionary = dict;
|
||||
return dict;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.ObjectArrayParam)]
|
||||
public object[] HandleObjectArray(object[] values)
|
||||
{
|
||||
ObjectArrayMethodCalled = true;
|
||||
ReceivedObjectArray = values;
|
||||
return values;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mixed Parameter Handlers
|
||||
|
||||
[SignalR(TestSignalRTags.IntAndDtoParam)]
|
||||
public string HandleIntAndDto(int id, TestOrderItem item)
|
||||
{
|
||||
IntAndDtoMethodCalled = true;
|
||||
ReceivedIntAndDto = (id, item);
|
||||
return $"{id}-{item?.ProductName}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DtoAndListParam)]
|
||||
public string HandleDtoAndList(TestOrderItem item, List<int> numbers)
|
||||
{
|
||||
DtoAndListMethodCalled = true;
|
||||
ReceivedDtoAndList = (item, numbers);
|
||||
return $"{item?.ProductName}-[{string.Join(",", numbers ?? [])}]";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.ThreeComplexParams)]
|
||||
public string HandleThreeComplexParams(TestOrderItem item, List<string> tags, SharedTag sharedTag)
|
||||
{
|
||||
ThreeComplexParamsMethodCalled = true;
|
||||
ReceivedThreeComplexParams = (item, tags, sharedTag);
|
||||
return $"{item?.ProductName}-{tags?.Count}-{sharedTag?.Name}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.FiveParams)]
|
||||
public string HandleFiveParams(int a, string b, bool c, Guid d, decimal e)
|
||||
{
|
||||
FiveParamsMethodCalled = true;
|
||||
ReceivedFiveParams = (a, b, c, d, e);
|
||||
return $"{a}-{b}-{c}-{d}-{e}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
// Primitive captures
|
||||
SingleIntMethodCalled = false;
|
||||
ReceivedInt = null;
|
||||
TwoIntMethodCalled = false;
|
||||
ReceivedTwoInts = null;
|
||||
BoolMethodCalled = false;
|
||||
ReceivedBool = null;
|
||||
StringMethodCalled = false;
|
||||
ReceivedString = null;
|
||||
GuidMethodCalled = false;
|
||||
ReceivedGuid = null;
|
||||
EnumMethodCalled = false;
|
||||
ReceivedEnum = null;
|
||||
NoParamsMethodCalled = false;
|
||||
MultipleTypesMethodCalled = false;
|
||||
ReceivedMultipleTypes = null;
|
||||
DecimalMethodCalled = false;
|
||||
ReceivedDecimal = null;
|
||||
DateTimeMethodCalled = false;
|
||||
ReceivedDateTime = null;
|
||||
DoubleMethodCalled = false;
|
||||
ReceivedDouble = null;
|
||||
LongMethodCalled = false;
|
||||
ReceivedLong = null;
|
||||
|
||||
// Complex object captures
|
||||
TestOrderItemMethodCalled = false;
|
||||
ReceivedTestOrderItem = null;
|
||||
TestOrderMethodCalled = false;
|
||||
ReceivedTestOrder = null;
|
||||
SharedTagMethodCalled = false;
|
||||
ReceivedSharedTag = null;
|
||||
|
||||
// Collection captures
|
||||
IntArrayMethodCalled = false;
|
||||
ReceivedIntArray = null;
|
||||
GuidArrayMethodCalled = false;
|
||||
ReceivedGuidArray = null;
|
||||
StringListMethodCalled = false;
|
||||
ReceivedStringList = null;
|
||||
TestOrderItemListMethodCalled = false;
|
||||
ReceivedTestOrderItemList = null;
|
||||
IntListMethodCalled = false;
|
||||
ReceivedIntList = null;
|
||||
BoolArrayMethodCalled = false;
|
||||
ReceivedBoolArray = null;
|
||||
MixedWithArrayMethodCalled = false;
|
||||
ReceivedMixedWithArray = null;
|
||||
NestedListMethodCalled = false;
|
||||
ReceivedNestedList = null;
|
||||
|
||||
// Extended array captures
|
||||
LongArrayMethodCalled = false;
|
||||
ReceivedLongArray = null;
|
||||
DecimalArrayMethodCalled = false;
|
||||
ReceivedDecimalArray = null;
|
||||
DateTimeArrayMethodCalled = false;
|
||||
ReceivedDateTimeArray = null;
|
||||
EnumArrayMethodCalled = false;
|
||||
ReceivedEnumArray = null;
|
||||
DoubleArrayMethodCalled = false;
|
||||
ReceivedDoubleArray = null;
|
||||
SharedTagArrayMethodCalled = false;
|
||||
ReceivedSharedTagArray = null;
|
||||
DictionaryMethodCalled = false;
|
||||
ReceivedDictionary = null;
|
||||
ObjectArrayMethodCalled = false;
|
||||
ReceivedObjectArray = null;
|
||||
|
||||
// Mixed parameter captures
|
||||
IntAndDtoMethodCalled = false;
|
||||
ReceivedIntAndDto = null;
|
||||
DtoAndListMethodCalled = false;
|
||||
ReceivedDtoAndList = null;
|
||||
ThreeComplexParamsMethodCalled = false;
|
||||
ReceivedThreeComplexParams = null;
|
||||
FiveParamsMethodCalled = false;
|
||||
ReceivedFiveParams = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
using System.Globalization;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Services.SignalRs;
|
||||
|
||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Test service with SignalR-attributed methods for testing ProcessOnReceiveMessage.
|
||||
/// Uses shared DTOs from AyCode.Core.Tests.TestModels.
|
||||
/// </summary>
|
||||
public class TestSignalRService2
|
||||
{
|
||||
#region Primitive Parameter Handlers
|
||||
|
||||
[SignalR(TestSignalRTags.SingleIntParam)]
|
||||
public string HandleSingleInt(int value)
|
||||
{
|
||||
return $"{value}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.TwoIntParams)]
|
||||
public int HandleTwoInts(int a, int b)
|
||||
{
|
||||
return a + b;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.BoolParam)]
|
||||
public bool HandleBool(bool loadRelations)
|
||||
{
|
||||
return loadRelations;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.StringParam)]
|
||||
public string HandleString(string text)
|
||||
{
|
||||
return $"Echo: {text}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.GuidParam)]
|
||||
public Guid HandleGuid(Guid id)
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.EnumParam)]
|
||||
public TestStatus HandleEnum(TestStatus status)
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.NoParams)]
|
||||
public string HandleNoParams()
|
||||
{
|
||||
return "OK";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.MultipleTypesParams)]
|
||||
public string HandleMultipleTypes(bool flag, string text, int number)
|
||||
{
|
||||
return $"{flag}-{text}-{number}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.ThrowsException)]
|
||||
public void HandleThrowsException()
|
||||
{
|
||||
throw new InvalidOperationException("Test exception");
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DecimalParam)]
|
||||
public decimal HandleDecimal(decimal value)
|
||||
{
|
||||
return value * 2;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DateTimeParam)]
|
||||
public DateTime HandleDateTime(DateTime dateTime)
|
||||
{
|
||||
return dateTime;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DoubleParam)]
|
||||
public double HandleDouble(double value)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.LongParam)]
|
||||
public long HandleLong(long value)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complex Object Handlers (using shared DTOs)
|
||||
|
||||
[SignalR(TestSignalRTags.TestOrderItemParam)]
|
||||
public TestOrderItem HandleTestOrderItem(TestOrderItem item)
|
||||
{
|
||||
return new TestOrderItem
|
||||
{
|
||||
Id = item.Id,
|
||||
ProductName = $"Processed: {item.ProductName}",
|
||||
Quantity = item.Quantity * 2,
|
||||
UnitPrice = item.UnitPrice * 2,
|
||||
};
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.TestOrderParam)]
|
||||
public TestOrder HandleTestOrder(TestOrder order)
|
||||
{
|
||||
return order;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.SharedTagParam)]
|
||||
public SharedTag HandleSharedTag(SharedTag tag)
|
||||
{
|
||||
return tag;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Collection Parameter Handlers
|
||||
|
||||
[SignalR(TestSignalRTags.IntArrayParam)]
|
||||
public int[] HandleIntArray(int[] values)
|
||||
{
|
||||
return values.Select(x => x * 2).ToArray();
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.GuidArrayParam)]
|
||||
public Guid[] HandleGuidArray(Guid[] ids)
|
||||
{
|
||||
return ids;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.StringListParam)]
|
||||
public List<string> HandleStringList(List<string> items)
|
||||
{
|
||||
return items.Select(x => x.ToUpper()).ToList();
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.TestOrderItemListParam)]
|
||||
public List<TestOrderItem> HandleTestOrderItemList(List<TestOrderItem> items)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.IntListParam)]
|
||||
public List<int> HandleIntList(List<int> numbers)
|
||||
{
|
||||
return numbers.Select(x => x * 2).ToList();
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.BoolArrayParam)]
|
||||
public bool[] HandleBoolArray(bool[] flags)
|
||||
{
|
||||
return flags;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.MixedWithArrayParam)]
|
||||
public string HandleMixedWithArray(bool flag, int[] numbers, string text)
|
||||
{
|
||||
return $"{flag}-[{string.Join(",", numbers)}]-{text}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.NestedListParam)]
|
||||
public List<List<int>> HandleNestedList(List<List<int>> nestedList)
|
||||
{
|
||||
return nestedList;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extended Array Parameter Handlers
|
||||
|
||||
[SignalR(TestSignalRTags.LongArrayParam)]
|
||||
public long[] HandleLongArray(long[] values)
|
||||
{
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DecimalArrayParam)]
|
||||
public decimal[] HandleDecimalArray(decimal[] values)
|
||||
{
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DateTimeArrayParam)]
|
||||
public DateTime[] HandleDateTimeArray(DateTime[] values)
|
||||
{
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.EnumArrayParam)]
|
||||
public TestStatus[] HandleEnumArray(TestStatus[] values)
|
||||
{
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DoubleArrayParam)]
|
||||
public double[] HandleDoubleArray(double[] values)
|
||||
{
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.SharedTagArrayParam)]
|
||||
public SharedTag[] HandleSharedTagArray(SharedTag[] tags)
|
||||
{
|
||||
return tags;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DictionaryParam)]
|
||||
public Dictionary<string, int> HandleDictionary(Dictionary<string, int> dict)
|
||||
{
|
||||
return dict;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.ObjectArrayParam)]
|
||||
public object[] HandleObjectArray(object[] values)
|
||||
{
|
||||
return values;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mixed Parameter Handlers
|
||||
|
||||
[SignalR(TestSignalRTags.IntAndDtoParam)]
|
||||
public string HandleIntAndDto(int id, TestOrderItem item)
|
||||
{
|
||||
return $"{id}-{item?.ProductName}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DtoAndListParam)]
|
||||
public string HandleDtoAndList(TestOrderItem item, List<int> numbers)
|
||||
{
|
||||
return $"{item?.ProductName}-[{string.Join(",", numbers ?? [])}]";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.ThreeComplexParams)]
|
||||
public string HandleThreeComplexParams(TestOrderItem item, List<string> tags, SharedTag sharedTag)
|
||||
{
|
||||
return $"{item?.ProductName}-{tags?.Count}-{sharedTag?.Name}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.FiveParams)]
|
||||
public Task<string> HandleFiveParams(int a, string b, bool c, Guid d, decimal e)
|
||||
{
|
||||
return Task.FromResult($"{a}-{b}-{c}-{d}-{e.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Async Task<T> Method Tests
|
||||
|
||||
[SignalR(TestSignalRTags.AsyncTestOrderItemParam)]
|
||||
public async Task<TestOrderItem> HandleAsyncTestOrderItem(TestOrderItem item)
|
||||
{
|
||||
await Task.Delay(1); // Simulate async work
|
||||
return new TestOrderItem
|
||||
{
|
||||
Id = item.Id,
|
||||
ProductName = $"Async: {item.ProductName}",
|
||||
Quantity = item.Quantity * 3,
|
||||
UnitPrice = item.UnitPrice * 3,
|
||||
};
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.AsyncStringParam)]
|
||||
public async Task<string> HandleAsyncString(string input)
|
||||
{
|
||||
await Task.Delay(1);
|
||||
return $"Async: {input}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.AsyncNoParams)]
|
||||
public async Task<string> HandleAsyncNoParams()
|
||||
{
|
||||
await Task.Delay(1);
|
||||
return "AsyncOK";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.AsyncIntParam)]
|
||||
public async Task<int> HandleAsyncInt(int value)
|
||||
{
|
||||
await Task.Delay(1);
|
||||
return value * 2;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Task.FromResult Tests - Critical for testing non-async methods returning Task
|
||||
// PRODUCTION BUG FIX: These methods test the scenario where a method returns Task<T>
|
||||
// using Task.FromResult() instead of async/await. Such methods do NOT have
|
||||
// AsyncStateMachineAttribute, so the old InvokeMethod implementation would serialize
|
||||
// the Task wrapper instead of awaiting and returning the actual result.
|
||||
|
||||
/// <summary>
|
||||
/// Returns Task without async keyword - uses Task.FromResult().
|
||||
/// This pattern does NOT have AsyncStateMachineAttribute!
|
||||
/// </summary>
|
||||
[SignalR(TestSignalRTags.TaskFromResultStringParam)]
|
||||
public Task<string> HandleTaskFromResultString(string input)
|
||||
{
|
||||
return Task.FromResult($"FromResult: {input}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns Task<TestOrderItem> without async keyword.
|
||||
/// CRITICAL: This simulates the exact production bug scenario.
|
||||
/// </summary>
|
||||
[SignalR(TestSignalRTags.TaskFromResultTestOrderItemParam)]
|
||||
public Task<TestOrderItem> HandleTaskFromResultTestOrderItem(TestOrderItem item)
|
||||
{
|
||||
return Task.FromResult(new TestOrderItem
|
||||
{
|
||||
Id = item.Id,
|
||||
ProductName = $"FromResult: {item.ProductName}",
|
||||
Quantity = item.Quantity * 2,
|
||||
UnitPrice = item.UnitPrice * 2,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns Task<int> without async keyword.
|
||||
/// </summary>
|
||||
[SignalR(TestSignalRTags.TaskFromResultIntParam)]
|
||||
public Task<int> HandleTaskFromResultInt(int value)
|
||||
{
|
||||
return Task.FromResult(value * 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns non-generic Task using Task.CompletedTask.
|
||||
/// </summary>
|
||||
[SignalR(TestSignalRTags.TaskFromResultNoParams)]
|
||||
public Task HandleTaskFromResultNoParams()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
using AyCode.Services.SignalRs;
|
||||
|
||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR message tags for testing.
|
||||
/// </summary>
|
||||
public abstract class TestSignalRTags : AcSignalRTags
|
||||
{
|
||||
// Primitive parameter tags
|
||||
public const int SingleIntParam = 100;
|
||||
public const int TwoIntParams = 101;
|
||||
public const int BoolParam = 102;
|
||||
public const int StringParam = 103;
|
||||
public const int GuidParam = 104;
|
||||
public const int EnumParam = 105;
|
||||
public const int NoParams = 107;
|
||||
public const int MultipleTypesParams = 109;
|
||||
public const int ThrowsException = 110;
|
||||
|
||||
// Extended primitives
|
||||
public const int DecimalParam = 140;
|
||||
public const int DateTimeParam = 141;
|
||||
public const int DoubleParam = 143;
|
||||
public const int LongParam = 144;
|
||||
|
||||
// Complex object parameter tags (using shared DTOs from Core.Tests)
|
||||
public const int TestOrderItemParam = 120;
|
||||
public const int TestOrderParam = 121;
|
||||
public const int SharedTagParam = 122;
|
||||
|
||||
// Collection parameter tags
|
||||
public const int IntArrayParam = 130;
|
||||
public const int GuidArrayParam = 131;
|
||||
public const int StringListParam = 132;
|
||||
public const int TestOrderItemListParam = 133;
|
||||
public const int IntListParam = 134;
|
||||
public const int BoolArrayParam = 135;
|
||||
public const int MixedWithArrayParam = 136;
|
||||
public const int NestedListParam = 151;
|
||||
|
||||
// Extended array/collection parameter tags for comprehensive testing
|
||||
public const int LongArrayParam = 170;
|
||||
public const int DecimalArrayParam = 171;
|
||||
public const int DateTimeArrayParam = 172;
|
||||
public const int EnumArrayParam = 173;
|
||||
public const int DoubleArrayParam = 174;
|
||||
public const int SharedTagArrayParam = 175;
|
||||
public const int DictionaryParam = 176;
|
||||
public const int ObjectArrayParam = 177;
|
||||
|
||||
// Mixed parameter scenarios
|
||||
public const int IntAndDtoParam = 160;
|
||||
public const int DtoAndListParam = 161;
|
||||
public const int ThreeComplexParams = 162;
|
||||
public const int FiveParams = 164;
|
||||
|
||||
// Async Task<T> method tags - critical for testing async handling
|
||||
public const int AsyncTestOrderItemParam = 200;
|
||||
public const int AsyncStringParam = 201;
|
||||
public const int AsyncNoParams = 202;
|
||||
public const int AsyncIntParam = 203;
|
||||
|
||||
// Task.FromResult method tags - CRITICAL for testing non-async methods returning Task
|
||||
// These methods do NOT have AsyncStateMachineAttribute but return Task<T>
|
||||
public const int TaskFromResultStringParam = 210;
|
||||
public const int TaskFromResultTestOrderItemParam = 211;
|
||||
public const int TaskFromResultIntParam = 212;
|
||||
public const int TaskFromResultNoParams = 213;
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using AyCode.Core;
|
||||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Services.Server.SignalRs;
|
||||
using AyCode.Services.SignalRs;
|
||||
using AyCode.Services.Tests.SignalRs;
|
||||
using MessagePack.Resolvers;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Testable SignalR client that allows testing without real HubConnection.
|
||||
/// </summary>
|
||||
public class TestableSignalRClient2 : AcSignalRClientBase, IAcSignalRHubItemServer
|
||||
{
|
||||
private HubConnectionState _connectionState = HubConnectionState.Connected;
|
||||
private readonly TestableSignalRHub2 _signalRHub;
|
||||
|
||||
/// <summary>
|
||||
/// Testable SignalR client that allows testing without real HubConnection.
|
||||
/// </summary>
|
||||
public TestableSignalRClient2(TestableSignalRHub2 signalRHub, TestLogger logger) : base(logger)
|
||||
{
|
||||
MsDelay = 0;
|
||||
MsFirstDelay = 0;
|
||||
|
||||
_signalRHub = signalRHub;
|
||||
}
|
||||
|
||||
#region Override virtual methods for testing
|
||||
|
||||
protected override async Task MessageReceived(int messageTag, byte[] messageBytes)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected override HubConnectionState GetConnectionState() => _connectionState;
|
||||
|
||||
protected override bool IsConnected() => _connectionState == HubConnectionState.Connected;
|
||||
|
||||
protected override Task StartConnectionInternal()
|
||||
{
|
||||
_connectionState = HubConnectionState.Connected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task StopConnectionInternal()
|
||||
{
|
||||
_connectionState = HubConnectionState.Disconnected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask;
|
||||
|
||||
protected override async Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId)
|
||||
{
|
||||
await _signalRHub.OnReceiveMessage(messageTag, messageBytes, requestId);
|
||||
}
|
||||
#endregion
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
using System.Security.Claims;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Models.Server.DynamicMethods;
|
||||
using AyCode.Services.Server.SignalRs;
|
||||
using AyCode.Services.SignalRs;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Testable SignalR hub that overrides infrastructure dependencies.
|
||||
/// Enables unit testing without SignalR server or mocks.
|
||||
/// </summary>
|
||||
public class TestableSignalRHub : AcWebSignalRHubBase<TestSignalRTags, TestLogger>
|
||||
{
|
||||
#region Captured Data for Assertions
|
||||
|
||||
/// <summary>
|
||||
/// Messages sent via ResponseToCaller or SendMessageToClient
|
||||
/// </summary>
|
||||
public List<SentMessage> SentMessages { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether notFoundCallback was invoked
|
||||
/// </summary>
|
||||
public bool WasNotFoundCallbackInvoked { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The tag name passed to notFoundCallback
|
||||
/// </summary>
|
||||
public string? NotFoundTagName { get; private set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Configuration
|
||||
|
||||
/// <summary>
|
||||
/// Simulated connection ID
|
||||
/// </summary>
|
||||
public string TestConnectionId { get; set; } = "test-connection-id";
|
||||
|
||||
/// <summary>
|
||||
/// Simulated user identifier
|
||||
/// </summary>
|
||||
public string? TestUserIdentifier { get; set; } = "test-user-id";
|
||||
|
||||
/// <summary>
|
||||
/// Simulated connection aborted state
|
||||
/// </summary>
|
||||
public bool TestIsConnectionAborted { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Simulated ClaimsPrincipal (optional)
|
||||
/// </summary>
|
||||
public ClaimsPrincipal? TestUser { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
public TestableSignalRHub()
|
||||
: base(new ConfigurationBuilder().Build(), new TestLogger())
|
||||
{
|
||||
}
|
||||
|
||||
public TestableSignalRHub(IConfiguration configuration, TestLogger logger)
|
||||
: base(configuration, logger)
|
||||
{
|
||||
}
|
||||
|
||||
#region Public Test Entry Points
|
||||
|
||||
/// <summary>
|
||||
/// Register a service with SignalR-attributed methods
|
||||
/// </summary>
|
||||
public void RegisterService(object service)
|
||||
{
|
||||
DynamicMethodCallModels.Add(new AcDynamicMethodCallModel<SignalRAttribute>(service));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoke ProcessOnReceiveMessage for testing
|
||||
/// </summary>
|
||||
public Task InvokeProcessOnReceiveMessage(int messageTag, byte[]? message, int? requestId = null)
|
||||
{
|
||||
return ProcessOnReceiveMessage(messageTag, message, requestId, async tagName =>
|
||||
{
|
||||
WasNotFoundCallbackInvoked = true;
|
||||
NotFoundTagName = tagName;
|
||||
await Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the logger for assertions
|
||||
/// </summary>
|
||||
public new TestLogger Logger => base.Logger;
|
||||
|
||||
/// <summary>
|
||||
/// Reset captured state for next test
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
SentMessages.Clear();
|
||||
WasNotFoundCallbackInvoked = false;
|
||||
NotFoundTagName = null;
|
||||
Logger.Clear();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Overridden Context Accessors
|
||||
|
||||
protected override string GetConnectionId() => TestConnectionId;
|
||||
|
||||
protected override bool IsConnectionAborted() => TestIsConnectionAborted;
|
||||
|
||||
protected override string? GetUserIdentifier() => TestUserIdentifier;
|
||||
|
||||
protected override ClaimsPrincipal? GetUser() => TestUser;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Overridden Response Methods (capture messages for testing)
|
||||
|
||||
protected override Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId)
|
||||
{
|
||||
SentMessages.Add(new SentMessage(
|
||||
MessageTag: messageTag,
|
||||
Message: message,
|
||||
RequestId: requestId,
|
||||
Target: SendTarget.Caller
|
||||
));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null)
|
||||
{
|
||||
SentMessages.Add(new SentMessage(
|
||||
MessageTag: messageTag,
|
||||
Message: message,
|
||||
RequestId: requestId,
|
||||
Target: SendTarget.Client
|
||||
));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task SendMessageToOthers(int messageTag, object? content)
|
||||
{
|
||||
SentMessages.Add(new SentMessage(
|
||||
MessageTag: messageTag,
|
||||
Message: new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content),
|
||||
RequestId: null,
|
||||
Target: SendTarget.Others
|
||||
));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task SendMessageToAll(int messageTag, object? content)
|
||||
{
|
||||
SentMessages.Add(new SentMessage(
|
||||
MessageTag: messageTag,
|
||||
Message: new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content),
|
||||
RequestId: null,
|
||||
Target: SendTarget.All
|
||||
));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task SendMessageToUserIdInternal(string userId, int messageTag, ISignalRMessage message, int? requestId)
|
||||
{
|
||||
SentMessages.Add(new SentMessage(
|
||||
MessageTag: messageTag,
|
||||
Message: message,
|
||||
RequestId: requestId,
|
||||
Target: SendTarget.User,
|
||||
TargetId: userId
|
||||
));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captured sent message for assertions
|
||||
/// </summary>
|
||||
public record SentMessage(
|
||||
int MessageTag,
|
||||
ISignalRMessage Message,
|
||||
int? RequestId,
|
||||
SendTarget Target,
|
||||
string? TargetId = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Get the response as SignalResponseJsonMessage for inspection
|
||||
/// </summary>
|
||||
public SignalResponseJsonMessage? AsJsonResponse => Message as SignalResponseJsonMessage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Target of the sent message
|
||||
/// </summary>
|
||||
public enum SendTarget
|
||||
{
|
||||
Caller,
|
||||
Client,
|
||||
Others,
|
||||
All,
|
||||
User,
|
||||
Group
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
using System.Security.Claims;
|
||||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Helpers;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Models.Server.DynamicMethods;
|
||||
using AyCode.Services.Server.SignalRs;
|
||||
using AyCode.Services.SignalRs;
|
||||
using MessagePack.Resolvers;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Testable SignalR hub that overrides infrastructure dependencies.
|
||||
/// Enables unit testing without SignalR server or mocks.
|
||||
/// </summary>
|
||||
public class TestableSignalRHub2 : AcWebSignalRHubBase<TestSignalRTags, TestLogger>
|
||||
{
|
||||
private IAcSignalRHubItemServer _callerClient;
|
||||
|
||||
#region Test Configuration
|
||||
|
||||
/// <summary>
|
||||
/// Simulated connection ID
|
||||
/// </summary>
|
||||
public string TestConnectionId { get; set; } = "test-connection-id";
|
||||
|
||||
/// <summary>
|
||||
/// Simulated user identifier
|
||||
/// </summary>
|
||||
public string? TestUserIdentifier { get; set; } = "test-user-id";
|
||||
|
||||
/// <summary>
|
||||
/// Simulated connection aborted state
|
||||
/// </summary>
|
||||
public bool TestIsConnectionAborted { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Simulated ClaimsPrincipal (optional)
|
||||
/// </summary>
|
||||
public ClaimsPrincipal? TestUser { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
public TestableSignalRHub2()
|
||||
: base(new ConfigurationBuilder().Build(), new TestLogger())
|
||||
{
|
||||
}
|
||||
|
||||
public TestableSignalRHub2(IConfiguration configuration, TestLogger logger)
|
||||
: base(configuration, logger)
|
||||
{
|
||||
}
|
||||
|
||||
#region Public Test Entry Points
|
||||
|
||||
/// <summary>
|
||||
/// Register a service with SignalR-attributed methods
|
||||
/// </summary>
|
||||
public void RegisterService(object service, IAcSignalRHubItemServer callerClient)
|
||||
{
|
||||
_callerClient = callerClient;
|
||||
DynamicMethodCallModels.Add(new AcDynamicMethodCallModel<SignalRAttribute>(service));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Overridden Context Accessors
|
||||
|
||||
protected override string GetConnectionId() => TestConnectionId;
|
||||
|
||||
protected override bool IsConnectionAborted() => TestIsConnectionAborted;
|
||||
|
||||
protected override string? GetUserIdentifier() => TestUserIdentifier;
|
||||
|
||||
protected override ClaimsPrincipal? GetUser() => TestUser;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Overridden Response Methods (capture messages for testing)
|
||||
|
||||
protected override Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId)
|
||||
=> SendMessageToClient(_callerClient, messageTag, message, requestId);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// Re-export TestLogger from AyCode.Core.Tests for backward compatibility
|
||||
|
||||
namespace AyCode.Services.Server.Tests;
|
||||
|
|
@ -924,6 +924,7 @@ namespace AyCode.Services.Server.SignalRs
|
|||
|
||||
return SignalRClient.PostDataAsync(messageTag, item, response =>
|
||||
{
|
||||
//response.ResponseDataJson
|
||||
if (response.Status != SignalResponseStatus.Success || response.ResponseData == null)
|
||||
{
|
||||
if (TryRollbackItem(item.Id, out _)) return Task.CompletedTask;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System.Linq.Expressions;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Claims;
|
||||
using AyCode.Core;
|
||||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Helpers;
|
||||
|
|
@ -17,135 +16,69 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
|
|||
: Hub<IAcSignalRHubItemServer>, IAcSignalRHubServer where TSignalRTags : AcSignalRTags where TLogger : AcLoggerBase
|
||||
{
|
||||
protected readonly List<AcDynamicMethodCallModel<SignalRAttribute>> DynamicMethodCallModels = [];
|
||||
//protected readonly TIAM.Core.Loggers.Logger<AcWebSignalRHubBase<TSignalRTags>> Logger = new(logWriters.ToArray());
|
||||
protected TLogger Logger = logger;
|
||||
protected IConfiguration Configuration = configuration;
|
||||
|
||||
//private readonly ServiceProviderAPIController _serviceProviderApiController;
|
||||
//private readonly TransferDataAPIController _transferDataApiController;
|
||||
#region Connection Lifecycle
|
||||
|
||||
//_serviceProviderApiController = serviceProviderApiController;
|
||||
//_transferDataApiController = transferDataApiController;
|
||||
|
||||
// https://docs.microsoft.com/en-us/aspnet/core/signalr/hubs?view=aspnetcore-3.1#strongly-typed-hubs
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
Logger.Debug($"Server OnConnectedAsync; ConnectionId: {Context.ConnectionId}; UserIdentifier: {Context.UserIdentifier}");
|
||||
|
||||
Logger.Debug($"Server OnConnectedAsync; ConnectionId: {GetConnectionId()}; UserIdentifier: {GetUserIdentifier()}");
|
||||
LogContextUserNameAndId();
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
|
||||
//Clients.Caller.ConnectionId = Context.ConnectionId;
|
||||
//Clients.Caller.UserIdentifier = Context.UserIdentifier;
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
var logText = $"Server OnDisconnectedAsync; ConnectionId: {Context.ConnectionId}; UserIdentifier: {Context.UserIdentifier};";
|
||||
|
||||
if (exception == null) Logger.Debug(logText);
|
||||
else Logger.Error(logText, exception);
|
||||
var connectionId = GetConnectionId();
|
||||
var userIdentifier = GetUserIdentifier();
|
||||
|
||||
if (exception == null)
|
||||
Logger.Debug($"Server OnDisconnectedAsync; ConnectionId: {connectionId}; UserIdentifier: {userIdentifier}");
|
||||
else
|
||||
Logger.Error($"Server OnDisconnectedAsync; ConnectionId: {connectionId}; UserIdentifier: {userIdentifier}", exception);
|
||||
|
||||
LogContextUserNameAndId();
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Message Processing
|
||||
|
||||
public virtual Task OnReceiveMessage(int messageTag, byte[]? messageBytes, int? requestId)
|
||||
{
|
||||
return ProcessOnReceiveMessage(messageTag, messageBytes, requestId, null);
|
||||
}
|
||||
|
||||
protected async Task ProcessOnReceiveMessage(int messageTag, byte[]? message, int? requestId, Func<string, Task>? notFoundCallback)
|
||||
protected virtual async Task ProcessOnReceiveMessage(int messageTag, byte[]? message, int? requestId, Func<string, Task>? notFoundCallback)
|
||||
{
|
||||
var tagName = ConstHelper.NameByValue<TSignalRTags>(messageTag);
|
||||
var logText = $"Server OnReceiveMessage; {nameof(requestId)}: {requestId}; ConnectionId: {Context.ConnectionId}; {tagName}";
|
||||
|
||||
if (message is { Length: 0 }) Logger.Warning($"message.Length == 0! {logText}");
|
||||
else Logger.Debug($"[{message?.Length:N0}b] {logText}");
|
||||
if (message is { Length: 0 })
|
||||
{
|
||||
Logger.Warning($"message.Length == 0! Server OnReceiveMessage; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Debug($"[{message?.Length:N0}b] Server OnReceiveMessage; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (AcDomain.IsDeveloperVersion) LogContextUserNameAndId();
|
||||
|
||||
foreach (var methodsByDeclaringObject in DynamicMethodCallModels)
|
||||
if (TryFindAndInvokeMethod(messageTag, message, tagName, out var responseData))
|
||||
{
|
||||
if (!methodsByDeclaringObject.MethodsByMessageTag.TryGetValue(messageTag, out var methodInfoModel)) continue;
|
||||
|
||||
object[]? paramValues = null;
|
||||
|
||||
logText = $"Found dynamic method for the tag! method: {methodsByDeclaringObject.InstanceObject.GetType().Name}.{methodInfoModel.MethodInfo.Name}";
|
||||
|
||||
if (methodInfoModel.ParamInfos is { Length: > 0 })
|
||||
{
|
||||
Logger.Debug($"{logText}({string.Join(", ", methodInfoModel.ParamInfos.Select(x => x.Name))}); {tagName}");
|
||||
|
||||
paramValues = new object[methodInfoModel.ParamInfos.Length];
|
||||
|
||||
var firstParamType = methodInfoModel.ParamInfos[0].ParameterType;
|
||||
if (methodInfoModel.ParamInfos.Length > 1 || firstParamType == typeof(string) || firstParamType.IsEnum || firstParamType.IsValueType || firstParamType == typeof(DateTime))
|
||||
{
|
||||
var msg = message!.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>();
|
||||
|
||||
for (var i = 0; i < msg.PostData.Ids.Count; i++)
|
||||
{
|
||||
//var obj = (string)msg.PostData.Ids[i];
|
||||
//if (msg.PostData.Ids[i] is Guid id)
|
||||
//{
|
||||
// if (id.IsNullOrEmpty()) throw new NullReferenceException($"PostData.Id.IsNullOrEmpty(); Ids: {msg.PostData.Ids}");
|
||||
// paramValues[i] = id;
|
||||
//}
|
||||
//else if (Guid.TryParse(obj, out id))
|
||||
//{
|
||||
// if (id.IsNullOrEmpty()) throw new NullReferenceException($"PostData.Id.IsNullOrEmpty(); Ids: {msg.PostData.Ids}");
|
||||
// paramValues[i] = id;
|
||||
//}
|
||||
//else if (Enum.TryParse(methodInfoModel.ParameterType, obj, out var enumObj))
|
||||
//{
|
||||
// paramValues[i] = enumObj;
|
||||
//}
|
||||
//else paramValues[i] = Convert.ChangeType(obj, methodInfoModel.ParameterType);
|
||||
|
||||
var obj = msg.PostData.Ids[i];
|
||||
//var config = new MapperConfiguration(cfg =>
|
||||
//{
|
||||
// cfg.CreateMap(obj.GetType(), methodInfoModel.ParameterType);
|
||||
//});
|
||||
|
||||
//var mapper = new Mapper(config);
|
||||
//paramValues[i] = mapper.Map(obj, methodInfoModel.ParameterType);
|
||||
|
||||
//paramValues[i] = obj;
|
||||
|
||||
var a = Array.CreateInstance(methodInfoModel.ParamInfos[i].ParameterType, 1);
|
||||
|
||||
if (methodInfoModel.ParamInfos[i].ParameterType == typeof(Expression))
|
||||
{
|
||||
//var serializer = new ExpressionSerializer(new JsonSerializer());
|
||||
//paramValues[i] = serializer.DeserializeText((string)(obj.JsonTo(a.GetType()) as Array)?.GetValue(0)!);
|
||||
}
|
||||
else paramValues[i] = (obj.JsonTo(a.GetType()) as Array)?.GetValue(0)!;
|
||||
|
||||
}
|
||||
}
|
||||
else paramValues[0] = message!.MessagePackTo<SignalPostJsonDataMessage<object>>(MessagePackSerializerOptions.Standard).PostDataJson.JsonTo(firstParamType)!;
|
||||
}
|
||||
else Logger.Debug($"{logText}(); {tagName}");
|
||||
|
||||
var responseData = methodInfoModel.MethodInfo.InvokeMethod(methodsByDeclaringObject.InstanceObject, paramValues);
|
||||
var responseDataJson = new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, responseData);
|
||||
var responseDataJsonKiloBytes = System.Text.Encoding.Unicode.GetByteCount(responseDataJson.ResponseData!) / 1024;
|
||||
|
||||
//File.WriteAllText(Path.Combine("h:", $"{requestId}.json"), responseDataJson.ResponseData);
|
||||
|
||||
Logger.Debug($"[{responseDataJsonKiloBytes}kb] responseData serialized to json");
|
||||
|
||||
if (Logger.LogLevel <= LogLevel.Debug)
|
||||
{
|
||||
var responseDataJsonKiloBytes = System.Text.Encoding.Unicode.GetByteCount(responseDataJson.ResponseData ?? "") / 1024;
|
||||
Logger.Debug($"[{responseDataJsonKiloBytes}kb] responseData serialized to json");
|
||||
}
|
||||
|
||||
await ResponseToCaller(messageTag, responseDataJson, requestId);
|
||||
|
||||
if (methodInfoModel.Attribute.SendToOtherClientType != SendToClientType.None)
|
||||
SendMessageToOtherClients(methodInfoModel.Attribute.SendToOtherClientTag, responseData).Forget();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -160,59 +93,184 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
|
|||
await ResponseToCaller(messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Error), requestId);
|
||||
}
|
||||
|
||||
protected Task ResponseToCaller2(int messageTag, object? content)
|
||||
/// <summary>
|
||||
/// Finds and invokes the method registered for the given message tag.
|
||||
/// </summary>
|
||||
private bool TryFindAndInvokeMethod(int messageTag, byte[]? message, string tagName, out object? responseData)
|
||||
{
|
||||
responseData = null;
|
||||
|
||||
foreach (var methodsByDeclaringObject in DynamicMethodCallModels)
|
||||
{
|
||||
if (!methodsByDeclaringObject.MethodsByMessageTag.TryGetValue(messageTag, out var methodInfoModel))
|
||||
continue;
|
||||
|
||||
var methodName = $"{methodsByDeclaringObject.InstanceObject.GetType().Name}.{methodInfoModel.MethodInfo.Name}";
|
||||
var paramValues = DeserializeParameters(message, methodInfoModel, tagName, methodName);
|
||||
|
||||
if (paramValues == null)
|
||||
Logger.Debug($"Found dynamic method for the tag! method: {methodName}(); {tagName}");
|
||||
else
|
||||
Logger.Debug($"Found dynamic method for the tag! method: {methodName}({string.Join(", ", methodInfoModel.ParamInfos.Select(x => x.Name))}); {tagName}");
|
||||
|
||||
responseData = methodInfoModel.MethodInfo.InvokeMethod(methodsByDeclaringObject.InstanceObject, paramValues);
|
||||
|
||||
if (methodInfoModel.Attribute.SendToOtherClientType != SendToClientType.None)
|
||||
SendMessageToOthers(methodInfoModel.Attribute.SendToOtherClientTag, responseData).Forget();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes parameters from the message based on method signature.
|
||||
/// Returns null if no parameters needed, or throws if message is invalid.
|
||||
/// </summary>
|
||||
private static object[]? DeserializeParameters(byte[]? message, AcMethodInfoModel<SignalRAttribute> methodInfoModel, string tagName, string methodName)
|
||||
{
|
||||
if (methodInfoModel.ParamInfos is not { Length: > 0 })
|
||||
return null;
|
||||
|
||||
// Validate message - required when method has parameters
|
||||
if (message is null or { Length: 0 })
|
||||
throw new ArgumentException($"Message is null or empty but method '{methodName}' requires {methodInfoModel.ParamInfos.Length} parameter(s); {tagName}");
|
||||
|
||||
var paramValues = new object[methodInfoModel.ParamInfos.Length];
|
||||
var firstParamType = methodInfoModel.ParamInfos[0].ParameterType;
|
||||
|
||||
// Use IdMessage format for: multiple params OR primitives/strings/enums/value types
|
||||
if (methodInfoModel.ParamInfos.Length > 1 || IsPrimitiveOrStringOrEnum(firstParamType))
|
||||
{
|
||||
// Use ContractlessStandardResolver to match client serialization
|
||||
var msg = message.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
|
||||
|
||||
for (var i = 0; i < msg.PostData.Ids.Count; i++)
|
||||
{
|
||||
var paramType = methodInfoModel.ParamInfos[i].ParameterType;
|
||||
// Direct JSON deserialization using AcJsonDeserializer (supports primitives)
|
||||
paramValues[i] = AcJsonDeserializer.Deserialize(msg.PostData.Ids[i], paramType)!;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single complex object - try to detect format by checking if it's an IdMessage
|
||||
var msgJson = message.MessagePackTo<SignalPostJsonDataMessage<object>>(ContractlessStandardResolver.Options);
|
||||
var json = msgJson.PostDataJson;
|
||||
|
||||
// Check if the JSON is an IdMessage format (has "Ids" property)
|
||||
if (json.Contains("\"Ids\""))
|
||||
{
|
||||
// It's IdMessage format - deserialize as IdMessage and get first Id
|
||||
var idMsg = message.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
|
||||
if (idMsg.PostData.Ids.Count > 0)
|
||||
{
|
||||
paramValues[0] = AcJsonDeserializer.Deserialize(idMsg.PostData.Ids[0], firstParamType)!;
|
||||
return paramValues;
|
||||
}
|
||||
}
|
||||
|
||||
// Direct complex object format
|
||||
paramValues[0] = json.JsonTo(firstParamType)!;
|
||||
}
|
||||
|
||||
return paramValues;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a type should use IdMessage format (primitives, strings, enums, value types).
|
||||
/// NOTE: Arrays and collections are NOT included - they use PostDataJson format when sent as single parameter.
|
||||
/// </summary>
|
||||
private static bool IsPrimitiveOrStringOrEnum(Type type)
|
||||
{
|
||||
return type == typeof(string) ||
|
||||
type.IsEnum ||
|
||||
type.IsValueType ||
|
||||
type == typeof(DateTime);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response Methods
|
||||
|
||||
protected virtual Task ResponseToCallerWithContent(int messageTag, object? content)
|
||||
=> ResponseToCaller(messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null);
|
||||
|
||||
protected Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId)
|
||||
protected virtual Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId)
|
||||
=> SendMessageToClient(Clients.Caller, messageTag, message, requestId);
|
||||
|
||||
protected Task SendMessageToUserId2(string userId, int messageTag, object? content)
|
||||
=> SendMessageToUserId(userId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null);
|
||||
protected virtual Task SendMessageToUserIdWithContent(string userId, int messageTag, object? content)
|
||||
=> SendMessageToUserIdInternal(userId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null);
|
||||
|
||||
public Task SendMessageToUserId(string userId, int messageTag, ISignalRMessage message, int? requestId)
|
||||
protected virtual Task SendMessageToUserIdInternal(string userId, int messageTag, ISignalRMessage message, int? requestId)
|
||||
=> SendMessageToClient(Clients.User(userId), messageTag, message, requestId);
|
||||
|
||||
public Task SendMessageToConnectionId2(string connectionId, int messageTag, object? content)
|
||||
=> SendMessageToConnectionId(connectionId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null);
|
||||
protected virtual Task SendMessageToConnectionIdWithContent(string connectionId, int messageTag, object? content)
|
||||
=> SendMessageToConnectionIdInternal(connectionId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null);
|
||||
|
||||
public Task SendMessageToConnectionId(string connectionId, int messageTag, ISignalRMessage message, int? requestId)
|
||||
=> SendMessageToClient(Clients.Client(Context.ConnectionId), messageTag, message, requestId);
|
||||
protected virtual Task SendMessageToConnectionIdInternal(string connectionId, int messageTag, ISignalRMessage message, int? requestId)
|
||||
=> SendMessageToClient(Clients.Client(connectionId), messageTag, message, requestId);
|
||||
|
||||
public Task SendMessageToOtherClients(int messageTag, object? content)
|
||||
protected virtual Task SendMessageToOthers(int messageTag, object? content)
|
||||
=> SendMessageToClient(Clients.Others, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null);
|
||||
|
||||
public Task SendMessageToAllClients(int messageTag, object? content)
|
||||
protected virtual Task SendMessageToAll(int messageTag, object? content)
|
||||
=> SendMessageToClient(Clients.All, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null);
|
||||
|
||||
|
||||
protected async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null)
|
||||
protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null)
|
||||
{
|
||||
var responseDataMessagePack = message.ToMessagePack(ContractlessStandardResolver.Options);
|
||||
Logger.Debug($"[{(responseDataMessagePack.Length/1024)}kb] Server sending responseDataMessagePack to client; {nameof(requestId)}: {requestId}; Aborted: {Context.ConnectionAborted.IsCancellationRequested}; ConnectionId: {Context.ConnectionId}; {ConstHelper.NameByValue<TSignalRTags>(messageTag)}");
|
||||
var tagName = ConstHelper.NameByValue<TSignalRTags>(messageTag);
|
||||
|
||||
Logger.Debug($"[{responseDataMessagePack.Length / 1024}kb] Server sending message to client; requestId: {requestId}; Aborted: {IsConnectionAborted()}; ConnectionId: {GetConnectionId()}; {tagName}");
|
||||
|
||||
await sendTo.OnReceiveMessage(messageTag, responseDataMessagePack, requestId);
|
||||
|
||||
Logger.Debug($"Server sent responseDataMessagePack to client; {nameof(requestId)}: {requestId}; Aborted: {Context.ConnectionAborted.IsCancellationRequested}; ConnectionId: {Context.ConnectionId}; {ConstHelper.NameByValue<TSignalRTags>(messageTag)}");
|
||||
Logger.Debug($"Server sent message to client; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}");
|
||||
}
|
||||
|
||||
public async Task SendMessageToGroup(string groupId, int messageTag, string message)
|
||||
{
|
||||
//await Clients.Group(groupId).Post("", messageTag, message);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Context Accessor Methods (virtual for testing)
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection ID. Override in tests to avoid Context dependency.
|
||||
/// </summary>
|
||||
protected virtual string GetConnectionId() => Context.ConnectionId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the connection is aborted. Override in tests to avoid Context dependency.
|
||||
/// </summary>
|
||||
protected virtual bool IsConnectionAborted() => Context.ConnectionAborted.IsCancellationRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user identifier. Override in tests to avoid Context dependency.
|
||||
/// </summary>
|
||||
protected virtual string? GetUserIdentifier() => Context.UserIdentifier;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ClaimsPrincipal user. Override in tests to avoid Context dependency.
|
||||
/// </summary>
|
||||
protected virtual ClaimsPrincipal? GetUser() => Context.User;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Logging
|
||||
|
||||
//[Conditional("DEBUG")]
|
||||
protected virtual void LogContextUserNameAndId()
|
||||
{
|
||||
string? userName = null;
|
||||
var userId = Guid.Empty;
|
||||
var user = GetUser();
|
||||
if (user == null) return;
|
||||
|
||||
if (Context.User != null)
|
||||
{
|
||||
userName = Context.User.Identity?.Name;
|
||||
Guid.TryParse(Context.User.FindFirstValue(ClaimTypes.NameIdentifier), out userId);
|
||||
}
|
||||
var userName = user.Identity?.Name;
|
||||
Guid.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier), out var userId);
|
||||
|
||||
if (AcDomain.IsDeveloperVersion) Logger.WarningConditional($"SignalR.Context; userName: {userName}; userId: {userId}");
|
||||
else Logger.Debug($"SignalR.Context; userName: {userName}; userId: {userId}");
|
||||
if (AcDomain.IsDeveloperVersion)
|
||||
Logger.WarningConditional($"SignalR.Context; userName: {userName}; userId: {userId}");
|
||||
else
|
||||
Logger.Debug($"SignalR.Context; userName: {userName}; userId: {userId}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -5,14 +5,63 @@ namespace AyCode.Services.Server.SignalRs;
|
|||
|
||||
public static class ExtensionMethods
|
||||
{
|
||||
/// <summary>
|
||||
/// Invokes a method and properly unwraps Task/Task<T> results.
|
||||
/// Handles both async methods and methods returning Task directly (e.g., Task.FromResult).
|
||||
/// </summary>
|
||||
public static object? InvokeMethod(this MethodInfo methodInfo, object obj, params object[]? parameters)
|
||||
{
|
||||
if (methodInfo.GetCustomAttribute(typeof(AsyncStateMachineAttribute)) is AsyncStateMachineAttribute isAsyncTask)
|
||||
var result = methodInfo.Invoke(obj, parameters);
|
||||
|
||||
if (result == null)
|
||||
return null;
|
||||
|
||||
// Check if result is a Task (this handles both async methods AND Task.FromResult)
|
||||
if (result is Task task)
|
||||
{
|
||||
dynamic awaitable = methodInfo.Invoke(obj, parameters)!;
|
||||
return awaitable.GetAwaiter().GetResult();
|
||||
// Wait for task completion
|
||||
task.GetAwaiter().GetResult();
|
||||
|
||||
// Check if it's Task<T> to extract the actual result
|
||||
var taskType = task.GetType();
|
||||
if (taskType.IsGenericType)
|
||||
{
|
||||
// Get the Result property from Task<T>
|
||||
var resultProperty = taskType.GetProperty("Result");
|
||||
if (resultProperty != null)
|
||||
{
|
||||
return resultProperty.GetValue(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Non-generic Task - no result
|
||||
return null;
|
||||
}
|
||||
|
||||
return methodInfo.Invoke(obj, parameters);
|
||||
// Handle ValueTask<T>
|
||||
var type = result.GetType();
|
||||
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>))
|
||||
{
|
||||
// Convert ValueTask<T> to Task<T> and get result
|
||||
var asTaskMethod = type.GetMethod("AsTask");
|
||||
if (asTaskMethod != null)
|
||||
{
|
||||
var taskResult = (Task)asTaskMethod.Invoke(result, null)!;
|
||||
taskResult.GetAwaiter().GetResult();
|
||||
|
||||
var resultProperty = taskResult.GetType().GetProperty("Result");
|
||||
return resultProperty?.GetValue(taskResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-generic ValueTask
|
||||
if (result is ValueTask valueTask)
|
||||
{
|
||||
valueTask.AsTask().GetAwaiter().GetResult();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Not a Task - return directly
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="..//AyCode.Core.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="4.0.2" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="4.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AyCode.Core.Tests\AyCode.Core.Tests.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Services\AyCode.Services.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1 @@
|
|||
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,130 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Services.SignalRs;
|
||||
using MessagePack.Resolvers;
|
||||
|
||||
namespace AyCode.Services.Tests.SignalRs;
|
||||
|
||||
[TestClass]
|
||||
public class PostJsonDataMessageTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Debug_CreatePostMessage_ForInt()
|
||||
{
|
||||
// Test what CreatePostMessage produces for an int
|
||||
var message = CreatePostMessageTest(42);
|
||||
|
||||
Console.WriteLine($"Message type: {message.GetType().Name}");
|
||||
|
||||
if (message is SignalPostJsonDataMessage<IdMessage> idMsg)
|
||||
{
|
||||
Console.WriteLine($"PostDataJson: {idMsg.PostDataJson}");
|
||||
Console.WriteLine($"PostData.Ids.Count: {idMsg.PostData.Ids.Count}");
|
||||
Console.WriteLine($"PostData.Ids[0]: {idMsg.PostData.Ids[0]}");
|
||||
}
|
||||
|
||||
// Serialize to MessagePack
|
||||
var bytes = message.ToMessagePack(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"MessagePack bytes: {bytes.Length}");
|
||||
|
||||
// Deserialize as server would
|
||||
var deserialized = bytes.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"Deserialized PostDataJson: {deserialized.PostDataJson}");
|
||||
Console.WriteLine($"Deserialized PostData type: {deserialized.PostData?.GetType().Name}");
|
||||
Console.WriteLine($"Deserialized PostData.Ids.Count: {deserialized.PostData?.Ids.Count}");
|
||||
|
||||
Assert.IsNotNull(deserialized.PostData);
|
||||
Assert.AreEqual(1, deserialized.PostData.Ids.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(42)]
|
||||
[DataRow("45")]
|
||||
[DataRow(true)]
|
||||
public void IdMessage_FullRoundTrip_AnyParameter(object testValue)
|
||||
{
|
||||
dynamic GetValueByType(object value)
|
||||
{
|
||||
if (value is int valueInt) return valueInt;
|
||||
if (value is bool valueBool) return valueBool;
|
||||
if (value is string valueString) return valueString;
|
||||
|
||||
Assert.Fail($"Type of testValue not implemented");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 1: Client creates message for int parameter (like PostDataAsync<int, string>)
|
||||
Console.WriteLine("=== Step 1: Client creates message ===");
|
||||
|
||||
var idMessage = new IdMessage(GetValueByType(testValue));
|
||||
Console.WriteLine($"IdMessage.Ids[0]: '{idMessage.Ids[0]}'");
|
||||
|
||||
var clientMessage = new SignalPostJsonDataMessage<IdMessage>(idMessage);
|
||||
Console.WriteLine($"Client PostDataJson: '{clientMessage.PostDataJson}'");
|
||||
|
||||
// Step 2: Serialize to MessagePack (client sends)
|
||||
Console.WriteLine("\n=== Step 2: MessagePack serialization ===");
|
||||
var bytes = clientMessage.ToMessagePack(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"MessagePack bytes: {bytes.Length}");
|
||||
|
||||
// Step 3: Server deserializes
|
||||
Console.WriteLine("\n=== Step 3: Server deserializes ===");
|
||||
var serverMessage = bytes.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"Server PostDataJson: '{serverMessage.PostDataJson}'");
|
||||
Console.WriteLine($"Server PostData.Ids.Count: {serverMessage.PostData?.Ids.Count}");
|
||||
Console.WriteLine($"Server PostData.Ids[0]: '{serverMessage.PostData?.Ids[0]}'");
|
||||
|
||||
// Step 4: Server deserializes parameter
|
||||
Console.WriteLine("\n=== Step 4: Server deserializes parameter ===");
|
||||
var paramJson = serverMessage.PostData.Ids[0];
|
||||
Console.WriteLine($"Parameter JSON: '{paramJson}'");
|
||||
var paramValue = AcJsonDeserializer.Deserialize(paramJson, testValue.GetType());
|
||||
Console.WriteLine($"Deserialized int value: {paramValue}");
|
||||
|
||||
// Step 5: Service method returns string
|
||||
Console.WriteLine("\n=== Step 5: Service method returns ===");
|
||||
var serviceResult = $"{paramValue}"; // Like HandleSingleInt does
|
||||
Console.WriteLine($"Service result: '{serviceResult}'");
|
||||
|
||||
// Step 6: Server creates response
|
||||
Console.WriteLine("\n=== Step 6: Server creates response ===");
|
||||
var response = new SignalResponseJsonMessage(100, SignalResponseStatus.Success, serviceResult);
|
||||
Console.WriteLine($"Response.ResponseData: '{response.ResponseData}'");
|
||||
|
||||
// Step 7: Serialize response to MessagePack
|
||||
Console.WriteLine("\n=== Step 7: Response MessagePack ===");
|
||||
var responseBytes = response.ToMessagePack(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"Response MessagePack bytes: {responseBytes.Length}");
|
||||
|
||||
// Step 8: Client deserializes response
|
||||
Console.WriteLine("\n=== Step 8: Client deserializes response ===");
|
||||
var clientResponse = responseBytes.MessagePackTo<SignalResponseJsonMessage>(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"Client ResponseData: '{clientResponse.ResponseData}'");
|
||||
|
||||
// Step 9: Client deserializes to target type (string)
|
||||
Console.WriteLine("\n=== Step 9: Client deserializes to string ===");
|
||||
try
|
||||
{
|
||||
var finalResult = clientResponse.ResponseData.JsonTo<string>();
|
||||
|
||||
Console.WriteLine($"Final result: '{finalResult}'");
|
||||
Assert.AreEqual(GetValueByType(testValue).ToString(), finalResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"ERROR: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static ISignalRMessage CreatePostMessageTest<TPostData>(TPostData postData)
|
||||
{
|
||||
var type = typeof(TPostData);
|
||||
|
||||
if (type == typeof(string) || type.IsEnum || type.IsValueType || type == typeof(DateTime))
|
||||
{
|
||||
return new SignalPostJsonDataMessage<IdMessage>(new IdMessage(postData!));
|
||||
}
|
||||
|
||||
return new SignalPostJsonDataMessage<TPostData>(postData);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using AyCode.Services.SignalRs;
|
||||
|
||||
namespace AyCode.Services.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR message tags for client testing.
|
||||
/// </summary>
|
||||
public static class TestClientTags
|
||||
{
|
||||
// Basic operations
|
||||
public const int Ping = 1;
|
||||
public const int Echo = 2;
|
||||
public const int GetStatus = 3;
|
||||
|
||||
// CRUD operations
|
||||
public const int GetById = 10;
|
||||
public const int GetAll = 11;
|
||||
public const int Create = 12;
|
||||
public const int Update = 13;
|
||||
public const int Delete = 14;
|
||||
|
||||
// Complex operations
|
||||
public const int GetOrderWithItems = 20;
|
||||
public const int PostOrder = 21;
|
||||
public const int GetMultipleParams = 22;
|
||||
|
||||
// Error scenarios
|
||||
public const int NotFound = 100;
|
||||
public const int ServerError = 101;
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
using AyCode.Core;
|
||||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Services.SignalRs;
|
||||
using MessagePack.Resolvers;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace AyCode.Services.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Testable SignalR client that allows testing without real HubConnection.
|
||||
/// </summary>
|
||||
public class TestableSignalRClient : AcSignalRClientBase
|
||||
{
|
||||
private HubConnectionState _connectionState = HubConnectionState.Connected;
|
||||
private int? _nextRequestIdOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Messages sent to the server (captured for assertions).
|
||||
/// </summary>
|
||||
public List<SentClientMessage> SentMessages { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Received messages (captured for assertions).
|
||||
/// </summary>
|
||||
public List<ReceivedClientMessage> ReceivedMessages { get; } = [];
|
||||
|
||||
public TestableSignalRClient(TestLogger logger) : base(logger)
|
||||
{
|
||||
}
|
||||
|
||||
#region Override virtual methods for testing
|
||||
|
||||
protected override HubConnectionState GetConnectionState() => _connectionState;
|
||||
|
||||
protected override bool IsConnected() => _connectionState == HubConnectionState.Connected;
|
||||
|
||||
protected override Task StartConnectionInternal()
|
||||
{
|
||||
_connectionState = HubConnectionState.Connected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task StopConnectionInternal()
|
||||
{
|
||||
_connectionState = HubConnectionState.Disconnected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask;
|
||||
|
||||
protected override Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId)
|
||||
{
|
||||
SentMessages.Add(new SentClientMessage(messageTag, messageBytes, requestId));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override int GetNextRequestId()
|
||||
{
|
||||
if (_nextRequestIdOverride.HasValue)
|
||||
{
|
||||
var id = _nextRequestIdOverride.Value;
|
||||
_nextRequestIdOverride = id + 1; // Auto-increment for subsequent calls
|
||||
return id;
|
||||
}
|
||||
return AcDomain.NextUniqueInt32;
|
||||
}
|
||||
|
||||
protected override Task MessageReceived(int messageTag, byte[] messageBytes)
|
||||
{
|
||||
ReceivedMessages.Add(new ReceivedClientMessage(messageTag, messageBytes));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public test helpers (wrappers for protected methods)
|
||||
|
||||
/// <summary>
|
||||
/// Sets the simulated connection state.
|
||||
/// </summary>
|
||||
public void SetConnectionState(HubConnectionState state) => _connectionState = state;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the next request ID for deterministic testing.
|
||||
/// Will auto-increment for subsequent calls.
|
||||
/// </summary>
|
||||
public void SetNextRequestId(int id) => _nextRequestIdOverride = id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the pending requests dictionary (public wrapper for testing).
|
||||
/// </summary>
|
||||
public new System.Collections.Concurrent.ConcurrentDictionary<int, SignalRRequestModel> GetPendingRequests()
|
||||
=> base.GetPendingRequests();
|
||||
|
||||
/// <summary>
|
||||
/// Registers a pending request (public wrapper for testing).
|
||||
/// </summary>
|
||||
public new void RegisterPendingRequest(int requestId, SignalRRequestModel model)
|
||||
=> base.RegisterPendingRequest(requestId, model);
|
||||
|
||||
/// <summary>
|
||||
/// Clears pending requests (public wrapper for testing).
|
||||
/// </summary>
|
||||
public new void ClearPendingRequests() => base.ClearPendingRequests();
|
||||
|
||||
/// <summary>
|
||||
/// Simulates receiving a response from the server.
|
||||
/// </summary>
|
||||
public Task SimulateServerResponse(int requestId, int messageTag, SignalResponseStatus status, object? data = null)
|
||||
{
|
||||
var response = new SignalResponseJsonMessage(messageTag, status, data);
|
||||
var bytes = response.ToMessagePack(ContractlessStandardResolver.Options);
|
||||
return OnReceiveMessage(messageTag, bytes, requestId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates receiving a success response from the server.
|
||||
/// </summary>
|
||||
public Task SimulateSuccessResponse<T>(int requestId, int messageTag, T data)
|
||||
=> SimulateServerResponse(requestId, messageTag, SignalResponseStatus.Success, data);
|
||||
|
||||
/// <summary>
|
||||
/// Simulates receiving an error response from the server.
|
||||
/// </summary>
|
||||
public Task SimulateErrorResponse(int requestId, int messageTag)
|
||||
=> SimulateServerResponse(requestId, messageTag, SignalResponseStatus.Error);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last sent message.
|
||||
/// </summary>
|
||||
public SentClientMessage? LastSentMessage => SentMessages.LastOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Clears all captured messages.
|
||||
/// </summary>
|
||||
public void ClearMessages()
|
||||
{
|
||||
SentMessages.Clear();
|
||||
ReceivedMessages.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes OnReceiveMessage directly for testing.
|
||||
/// </summary>
|
||||
public Task InvokeOnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)
|
||||
=> OnReceiveMessage(messageTag, messageBytes, requestId);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a message sent from client to server.
|
||||
/// </summary>
|
||||
public record SentClientMessage(int MessageTag, byte[]? MessageBytes, int? RequestId)
|
||||
{
|
||||
/// <summary>
|
||||
/// Deserializes the message to IdMessage format.
|
||||
/// Works with both production SignalPostJsonDataMessage and test SignalRPostMessageDto.
|
||||
/// </summary>
|
||||
public IdMessage? AsIdMessage()
|
||||
{
|
||||
if (MessageBytes == null) return null;
|
||||
try
|
||||
{
|
||||
// First deserialize to get the PostDataJson string
|
||||
var msg = MessageBytes.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
|
||||
return msg.PostData;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback: try deserializing as raw JSON wrapper
|
||||
try
|
||||
{
|
||||
var rawMsg = MessageBytes.MessagePackTo<SignalPostJsonMessage>(ContractlessStandardResolver.Options);
|
||||
return rawMsg.PostDataJson?.JsonTo<IdMessage>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the message to a specific post data type.
|
||||
/// </summary>
|
||||
public T? AsPostData<T>() where T : class
|
||||
{
|
||||
if (MessageBytes == null) return null;
|
||||
try
|
||||
{
|
||||
var msg = MessageBytes.MessagePackTo<SignalPostJsonDataMessage<T>>(ContractlessStandardResolver.Options);
|
||||
return msg.PostData;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback: try deserializing as raw JSON wrapper
|
||||
try
|
||||
{
|
||||
var rawMsg = MessageBytes.MessagePackTo<SignalPostJsonMessage>(ContractlessStandardResolver.Options);
|
||||
return rawMsg.PostDataJson?.JsonTo<T>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a message received by the client.
|
||||
/// </summary>
|
||||
public record ReceivedClientMessage(int MessageTag, byte[] MessageBytes)
|
||||
{
|
||||
/// <summary>
|
||||
/// Deserializes the message as a response.
|
||||
/// </summary>
|
||||
public SignalResponseJsonMessage? AsResponse()
|
||||
{
|
||||
try
|
||||
{
|
||||
return MessageBytes.MessagePackTo<SignalResponseJsonMessage>(ContractlessStandardResolver.Options);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,16 +16,22 @@ namespace AyCode.Services.SignalRs
|
|||
{
|
||||
private readonly ConcurrentDictionary<int, SignalRRequestModel> _responseByRequestId = new();
|
||||
|
||||
protected readonly HubConnection HubConnection;
|
||||
protected readonly HubConnection? HubConnection;
|
||||
protected readonly AcLoggerBase Logger;
|
||||
|
||||
//protected event Action<int, byte[], int?> OnMessageReceived = null!;
|
||||
protected abstract Task MessageReceived(int messageTag, byte[] messageBytes);
|
||||
|
||||
public int MsDelay = 25;
|
||||
public int MsFirstDelay = 50;
|
||||
|
||||
public int ConnectionTimeout = 10000;
|
||||
public int TransportSendTimeout = 60000;
|
||||
private const string TagsName = "SignalRTags";
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor - creates and starts HubConnection.
|
||||
/// </summary>
|
||||
protected AcSignalRClientBase(string fullHubName, AcLoggerBase logger)
|
||||
{
|
||||
Logger = logger;
|
||||
|
|
@ -79,59 +85,162 @@ namespace AyCode.Services.SignalRs
|
|||
HubConnection.StartAsync().Forget();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test constructor - allows testing without real HubConnection.
|
||||
/// Override virtual methods to control behavior in tests.
|
||||
/// </summary>
|
||||
protected AcSignalRClientBase(AcLoggerBase logger)
|
||||
{
|
||||
Logger = logger;
|
||||
HubConnection = null;
|
||||
}
|
||||
|
||||
private Task HubConnection_Closed(Exception? arg)
|
||||
{
|
||||
if (_responseByRequestId.IsEmpty) Logger.DebugConditional($"Client HubConnection_Closed");
|
||||
else Logger.Warning($"Client HubConnection_Closed; {nameof(_responseByRequestId)} count: {_responseByRequestId.Count}");
|
||||
|
||||
_responseByRequestId.Clear();
|
||||
ClearPendingRequests();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#region Connection State Methods (virtual for testing)
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current connection state. Override in tests.
|
||||
/// </summary>
|
||||
protected virtual HubConnectionState GetConnectionState()
|
||||
=> HubConnection?.State ?? HubConnectionState.Disconnected;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the connection is connected. Override in tests.
|
||||
/// </summary>
|
||||
protected virtual bool IsConnected()
|
||||
=> GetConnectionState() == HubConnectionState.Connected;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the connection. Override in tests to avoid real connection.
|
||||
/// </summary>
|
||||
protected virtual Task StartConnectionInternal()
|
||||
{
|
||||
if (HubConnection == null) return Task.CompletedTask;
|
||||
return HubConnection.StartAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the connection. Override in tests.
|
||||
/// </summary>
|
||||
protected virtual Task StopConnectionInternal()
|
||||
{
|
||||
if (HubConnection == null) return Task.CompletedTask;
|
||||
return HubConnection.StopAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the connection. Override in tests.
|
||||
/// </summary>
|
||||
protected virtual ValueTask DisposeConnectionInternal()
|
||||
{
|
||||
if (HubConnection == null) return ValueTask.CompletedTask;
|
||||
return HubConnection.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a message to the server via HubConnection. Override in tests.
|
||||
/// </summary>
|
||||
protected virtual Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId)
|
||||
{
|
||||
if (HubConnection == null) return Task.CompletedTask;
|
||||
return HubConnection.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, messageBytes, requestId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Protected Test Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Gets the pending requests dictionary for testing.
|
||||
/// </summary>
|
||||
protected ConcurrentDictionary<int, SignalRRequestModel> GetPendingRequests()
|
||||
=> _responseByRequestId;
|
||||
|
||||
/// <summary>
|
||||
/// Clears all pending requests.
|
||||
/// </summary>
|
||||
protected void ClearPendingRequests()
|
||||
=> _responseByRequestId.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Registers a pending request for testing.
|
||||
/// </summary>
|
||||
protected void RegisterPendingRequest(int requestId, SignalRRequestModel model)
|
||||
=> _responseByRequestId[requestId] = model;
|
||||
|
||||
/// <summary>
|
||||
/// Simulates receiving a response for testing.
|
||||
/// </summary>
|
||||
protected void SimulateResponse(int requestId, ISignalResponseMessage<string> response)
|
||||
{
|
||||
if (_responseByRequestId.TryGetValue(requestId, out var model))
|
||||
{
|
||||
model.ResponseByRequestId = response;
|
||||
model.ResponseDateTime = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public async Task StartConnection()
|
||||
{
|
||||
if (HubConnection.State == HubConnectionState.Disconnected)
|
||||
await HubConnection.StartAsync();
|
||||
if (GetConnectionState() == HubConnectionState.Disconnected)
|
||||
await StartConnectionInternal();
|
||||
|
||||
if (HubConnection.State != HubConnectionState.Connected)
|
||||
await TaskHelper.WaitToAsync(() => HubConnection.State == HubConnectionState.Connected, ConnectionTimeout, 10, 25);
|
||||
if (!IsConnected())
|
||||
await TaskHelper.WaitToAsync(IsConnected, ConnectionTimeout, 10, 25);
|
||||
}
|
||||
|
||||
public async Task StopConnection()
|
||||
{
|
||||
await HubConnection.StopAsync();
|
||||
await HubConnection.DisposeAsync();
|
||||
await StopConnectionInternal();
|
||||
await DisposeConnectionInternal();
|
||||
}
|
||||
|
||||
public virtual Task SendMessageToServerAsync(int messageTag)
|
||||
=> SendMessageToServerAsync(messageTag, null, AcDomain.NextUniqueInt32);
|
||||
=> SendMessageToServerAsync(messageTag, null, GetNextRequestId());
|
||||
|
||||
public virtual Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId)
|
||||
public virtual async Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId)
|
||||
{
|
||||
Logger.DebugConditional($"Client SendMessageToServerAsync sending; {nameof(requestId)}: {requestId}; ConnectionSate: {HubConnection.State}; {ConstHelper.NameByValue(TagsName, messageTag)}");
|
||||
Logger.DebugConditional($"Client SendMessageToServerAsync sending; {nameof(requestId)}: {requestId}; ConnectionState: {GetConnectionState()}; {ConstHelper.NameByValue(TagsName, messageTag)}");
|
||||
|
||||
return StartConnection().ContinueWith(_ =>
|
||||
await StartConnection();
|
||||
|
||||
var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options);
|
||||
|
||||
if (!IsConnected())
|
||||
{
|
||||
var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options);
|
||||
Logger.Error($"Client SendMessageToServerAsync error! ConnectionState: {GetConnectionState()};");
|
||||
return;
|
||||
}
|
||||
|
||||
if (HubConnection.State != HubConnectionState.Connected)
|
||||
{
|
||||
Logger.Error($"Client SendMessageToServerAsync error! ConnectionSate: {HubConnection.State};");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return HubConnection.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, msgp, requestId);
|
||||
});
|
||||
await SendToHubAsync(messageTag, msgp, requestId);
|
||||
}
|
||||
|
||||
#region CRUD
|
||||
public virtual Task<TResponseData?> PostAsync<TResponseData>(int messageTag, object parameter) //where TResponseData : class
|
||||
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(parameter)), GetNextRequestId());
|
||||
|
||||
public virtual Task<TResponseData?> PostAsync<TResponseData>(int messageTag, object[] parameters) //where TResponseData : class
|
||||
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(parameters)), GetNextRequestId());
|
||||
|
||||
public virtual Task<TResponseData?> GetByIdAsync<TResponseData>(int messageTag, object id) //where TResponseData : class
|
||||
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(id)), AcDomain.NextUniqueInt32);
|
||||
=> PostAsync<TResponseData?>(messageTag, id);
|
||||
|
||||
public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object id)
|
||||
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(id)), responseCallback);
|
||||
|
||||
public virtual Task<TResponseData?> GetByIdAsync<TResponseData>(int messageTag, object[] ids) //where TResponseData : class
|
||||
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(ids)), AcDomain.NextUniqueInt32);
|
||||
=> PostAsync<TResponseData?>(messageTag, ids);
|
||||
|
||||
public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object[] ids)
|
||||
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(ids)), responseCallback);
|
||||
|
||||
|
|
@ -143,17 +252,49 @@ namespace AyCode.Services.SignalRs
|
|||
public virtual Task GetAllAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object[]? contextParams)
|
||||
=> SendMessageToServerAsync(messageTag, (contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams))), responseCallback);
|
||||
public virtual Task<TResponseData?> GetAllAsync<TResponseData>(int messageTag, object[]? contextParams) //where TResponseData : class
|
||||
=> SendMessageToServerAsync<TResponseData>(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams)), AcDomain.NextUniqueInt32);
|
||||
=> SendMessageToServerAsync<TResponseData>(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams)), GetNextRequestId());
|
||||
|
||||
public virtual Task<TPostData?> PostDataAsync<TPostData>(int messageTag, TPostData postData) where TPostData : class
|
||||
=> SendMessageToServerAsync<TPostData>(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), AcDomain.NextUniqueInt32);
|
||||
=> SendMessageToServerAsync<TPostData>(messageTag, CreatePostMessage(postData), GetNextRequestId());
|
||||
public virtual Task<TResponseData?> PostDataAsync<TPostData, TResponseData>(int messageTag, TPostData postData) //where TPostData : class where TResponseData : class
|
||||
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), AcDomain.NextUniqueInt32);
|
||||
=> SendMessageToServerAsync<TResponseData>(messageTag, CreatePostMessage(postData), GetNextRequestId());
|
||||
|
||||
public virtual Task PostDataAsync<TPostData>(int messageTag, TPostData postData, Func<ISignalResponseMessage<TPostData?>, Task> responseCallback) //where TPostData : class
|
||||
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), responseCallback);
|
||||
=> SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback);
|
||||
public virtual Task PostDataAsync<TPostData, TResponseData>(int messageTag, TPostData postData, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback) //where TPostData : class where TResponseData : class
|
||||
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), responseCallback);
|
||||
=> SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the appropriate message wrapper for the post data.
|
||||
/// Primitives, strings, enums, and value types are wrapped in IdMessage.
|
||||
/// Complex objects are sent directly in SignalPostJsonDataMessage.
|
||||
/// </summary>
|
||||
private static ISignalRMessage CreatePostMessage<TPostData>(TPostData postData)
|
||||
{
|
||||
var type = typeof(TPostData);
|
||||
|
||||
// Primitives, strings, enums, and value types should use IdMessage format
|
||||
if (IsPrimitiveOrStringOrEnum(type))
|
||||
{
|
||||
return new SignalPostJsonDataMessage<IdMessage>(new IdMessage(postData!));
|
||||
}
|
||||
|
||||
// Complex objects use direct serialization
|
||||
return new SignalPostJsonDataMessage<TPostData>(postData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a type should use IdMessage format (primitives, strings, enums, value types).
|
||||
/// Must match the logic in AcWebSignalRHubBase.IsPrimitiveOrStringOrEnum.
|
||||
/// NOTE: Arrays and collections are NOT included here - they are complex objects for PostDataAsync.
|
||||
/// </summary>
|
||||
private static bool IsPrimitiveOrStringOrEnum(Type type)
|
||||
{
|
||||
return type == typeof(string) ||
|
||||
type.IsEnum ||
|
||||
type.IsValueType ||
|
||||
type == typeof(DateTime);
|
||||
}
|
||||
|
||||
public Task GetAllIntoAsync<TResponseItem>(List<TResponseItem> intoList, int messageTag, object[]? contextParams = null, Action? callback = null) where TResponseItem : IEntityGuid
|
||||
{
|
||||
|
|
@ -178,28 +319,31 @@ namespace AyCode.Services.SignalRs
|
|||
#endregion CRUD
|
||||
|
||||
public virtual Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag) //where TResponse : class
|
||||
=> SendMessageToServerAsync<TResponse>(messageTag, null, AcDomain.NextUniqueInt32);
|
||||
=> SendMessageToServerAsync<TResponse>(messageTag, null, GetNextRequestId());
|
||||
|
||||
public virtual Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag, ISignalRMessage? message) //where TResponse : class
|
||||
=> SendMessageToServerAsync<TResponse>(messageTag, message, AcDomain.NextUniqueInt32);
|
||||
=> SendMessageToServerAsync<TResponse>(messageTag, message, GetNextRequestId());
|
||||
|
||||
protected virtual async Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag, ISignalRMessage? message, int requestId) //where TResponse : class
|
||||
{
|
||||
Logger.DebugConditional($"Client SendMessageToServerAsync<TResult>; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
|
||||
|
||||
var startTime = DateTime.Now;
|
||||
var requestModel = SignalRRequestModelPool.Get();
|
||||
|
||||
_responseByRequestId[requestId] = new SignalRRequestModel();
|
||||
_responseByRequestId[requestId] = requestModel;
|
||||
await SendMessageToServerAsync(messageTag, message, requestId);
|
||||
|
||||
try
|
||||
{
|
||||
if (await TaskHelper.WaitToAsync(() => /*HubConnection.State != HubConnectionState.Connected ||*/ _responseByRequestId[requestId].ResponseByRequestId != null, TransportSendTimeout, 25, 50) &&
|
||||
if (await TaskHelper.WaitToAsync(() => _responseByRequestId[requestId].ResponseByRequestId != null, TransportSendTimeout, MsDelay, MsFirstDelay) &&
|
||||
_responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is ISignalResponseMessage<string> responseMessage)
|
||||
{
|
||||
SignalRRequestModelPool.Return(obj);
|
||||
|
||||
if (responseMessage.Status == SignalResponseStatus.Error || responseMessage.ResponseData == null)
|
||||
{
|
||||
var errorText = $"Client SendMessageToServerAsync<TResponseData> response error; await; tag: {messageTag}; Status: {responseMessage.Status}; ConnectionState: {HubConnection?.State}; requestId: {requestId}";
|
||||
var errorText = $"Client SendMessageToServerAsync<TResponseData> response error; await; tag: {messageTag}; Status: {responseMessage.Status}; ConnectionState: {GetConnectionState()}; requestId: {requestId}";
|
||||
|
||||
Logger.Error(errorText);
|
||||
|
||||
|
|
@ -213,27 +357,29 @@ namespace AyCode.Services.SignalRs
|
|||
return responseMessage.ResponseData.JsonTo<TResponse>();
|
||||
}
|
||||
|
||||
Logger.Error($"Client timeout after: {(DateTime.Now - startTime).TotalSeconds} sec! ConnectionState: {HubConnection?.State}; requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
|
||||
Logger.Error($"Client timeout after: {(DateTime.Now - startTime).TotalSeconds} sec! ConnectionState: {GetConnectionState()}; requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"SendMessageToServerAsync; requestId: {requestId}; ConnectionState: {HubConnection?.State}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
|
||||
Logger.Error($"SendMessageToServerAsync; requestId: {requestId}; ConnectionState: {GetConnectionState()}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
|
||||
}
|
||||
|
||||
_responseByRequestId.TryRemove(requestId, out _);
|
||||
if (_responseByRequestId.TryRemove(requestId, out var removedModel))
|
||||
{
|
||||
SignalRRequestModelPool.Return(removedModel);
|
||||
}
|
||||
return default;
|
||||
}
|
||||
|
||||
public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
|
||||
=> SendMessageToServerAsync(messageTag, null, responseCallback);
|
||||
|
||||
public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, ISignalRMessage? message, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
|
||||
public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, ISignalRMessage? message, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
|
||||
{
|
||||
if (messageTag == 0) Logger.Error($"SendMessageToServerAsync; messageTag == 0");
|
||||
|
||||
var requestId = AcDomain.NextUniqueInt32;
|
||||
|
||||
_responseByRequestId[requestId] = new SignalRRequestModel(new Action<ISignalResponseMessage<string>>(responseMessage =>
|
||||
var requestId = GetNextRequestId();
|
||||
var requestModel = SignalRRequestModelPool.Get(new Action<ISignalResponseMessage<string>>(responseMessage =>
|
||||
{
|
||||
TResponseData? responseData = default;
|
||||
|
||||
|
|
@ -241,14 +387,21 @@ namespace AyCode.Services.SignalRs
|
|||
{
|
||||
responseData = string.IsNullOrEmpty(responseMessage.ResponseData) ? default : responseMessage.ResponseData.JsonTo<TResponseData?>();
|
||||
}
|
||||
else Logger.Error($"Client SendMessageToServerAsync<TResponseData> response error; callback; Status: {responseMessage.Status}; ConnectionState: {HubConnection?.State}; requestId: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
|
||||
else Logger.Error($"Client SendMessageToServerAsync<TResponseData> response error; callback; Status: {responseMessage.Status}; ConnectionState: {GetConnectionState()}; requestId: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
|
||||
|
||||
responseCallback(new SignalResponseMessage<TResponseData?>(messageTag, responseMessage.Status, responseData));
|
||||
}));
|
||||
|
||||
_responseByRequestId[requestId] = requestModel;
|
||||
|
||||
return SendMessageToServerAsync(messageTag, message, requestId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next unique request ID.
|
||||
/// </summary>
|
||||
protected virtual int GetNextRequestId() => AcDomain.NextUniqueInt32;
|
||||
|
||||
public virtual Task OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)
|
||||
{
|
||||
var logText = $"Client OnReceiveMessage; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}";
|
||||
|
|
@ -264,7 +417,7 @@ namespace AyCode.Services.SignalRs
|
|||
_responseByRequestId[reqId].ResponseDateTime = DateTime.UtcNow;
|
||||
Logger.Debug($"[{_responseByRequestId[reqId].ResponseDateTime.Subtract(_responseByRequestId[reqId].RequestDateTime).TotalMilliseconds:N0}ms][{(messageBytes.Length / 1024)}kb]{logText}");
|
||||
|
||||
var responseMessage = messageBytes.MessagePackTo<SignalResponseJsonMessage>(ContractlessStandardResolver.Options);
|
||||
var responseMessage = DeserializeResponseMsgPack(messageBytes);
|
||||
|
||||
switch (_responseByRequestId[reqId].ResponseByRequestId)
|
||||
{
|
||||
|
|
@ -273,55 +426,47 @@ namespace AyCode.Services.SignalRs
|
|||
return Task.CompletedTask;
|
||||
|
||||
case Action<ISignalResponseMessage<string>> messagePackCallback:
|
||||
_responseByRequestId.TryRemove(reqId, out _);
|
||||
if (_responseByRequestId.TryRemove(reqId, out var callbackModel))
|
||||
{
|
||||
SignalRRequestModelPool.Return(callbackModel);
|
||||
}
|
||||
|
||||
messagePackCallback.Invoke(responseMessage);
|
||||
return Task.CompletedTask;
|
||||
|
||||
//case Action<string> jsonCallback:
|
||||
// _responseByRequestId.TryRemove(reqId, out _);
|
||||
|
||||
// jsonCallback.Invoke(responseMessage);
|
||||
// return Task.CompletedTask;
|
||||
return Task.CompletedTask; // ← Callback: NEM hívjuk meg a MessageReceived-et
|
||||
|
||||
default:
|
||||
Logger.Error($"Client OnReceiveMessage switch; unknown message type: {_responseByRequestId[reqId].ResponseByRequestId?.GetType().Name}; {ConstHelper.NameByValue(TagsName, messageTag)}");
|
||||
break;
|
||||
}
|
||||
|
||||
_responseByRequestId.TryRemove(reqId, out _);
|
||||
if (_responseByRequestId.TryRemove(reqId, out var removedModel))
|
||||
{
|
||||
SignalRRequestModelPool.Return(removedModel);
|
||||
}
|
||||
|
||||
// Request-response hibás eset - ne hívjuk meg a MessageReceived-et
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
else Logger.Info(logText);
|
||||
|
||||
// Csak broadcast/notification üzeneteknél hívjuk meg a MessageReceived-et
|
||||
Logger.Info(logText);
|
||||
MessageReceived(messageTag, messageBytes).Forget();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (requestId.HasValue)
|
||||
_responseByRequestId.TryRemove(requestId.Value, out _);
|
||||
if (requestId.HasValue && _responseByRequestId.TryRemove(requestId.Value, out var exModel))
|
||||
{
|
||||
SignalRRequestModelPool.Return(exModel);
|
||||
}
|
||||
|
||||
Logger.Error($"Client OnReceiveMessage; ConnectionState: {HubConnection?.State}; requestId: {requestId}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
|
||||
Logger.Error($"Client OnReceiveMessage; ConnectionState: {GetConnectionState()}; requestId: {requestId}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
|
||||
throw;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
//public virtual Task OnRequestMessage(int messageTag, int requestId)
|
||||
//{
|
||||
// Logger.DebugConditional($"Client OnRequestMessage; {nameof(messageTag)}: {messageTag}; {nameof(requestId)}: {requestId};");
|
||||
|
||||
// try
|
||||
// {
|
||||
// OnMessageRequested(messageTag, requestId);
|
||||
// }
|
||||
// catch(Exception ex)
|
||||
// {
|
||||
// Logger.Error($"Client OnReceiveMessage; {nameof(messageTag)}: {messageTag}; {nameof(requestId)}: {requestId}; {ex.Message}", ex);
|
||||
// throw;
|
||||
// }
|
||||
|
||||
// return Task.CompletedTask;
|
||||
|
||||
//}
|
||||
protected virtual SignalResponseJsonMessage DeserializeResponseMsgPack(byte[] messageBytes)
|
||||
=> messageBytes.MessagePackTo<SignalResponseJsonMessage>(ContractlessStandardResolver.Options);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,7 @@
|
|||
public class AcSignalRTags
|
||||
{
|
||||
public const int None = 0;
|
||||
|
||||
public const int PingTag = 90001;
|
||||
public const int EchoTag = 90002;
|
||||
}
|
||||
|
|
@ -10,47 +10,51 @@ namespace AyCode.Services.SignalRs;
|
|||
|
||||
public class IdMessage
|
||||
{
|
||||
public List<string> Ids { get; private set; } = [];
|
||||
public List<string> Ids { get; private set; }
|
||||
|
||||
public IdMessage()
|
||||
{
|
||||
Ids = [];
|
||||
}
|
||||
|
||||
public IdMessage(IEnumerable<object> ids) : this()
|
||||
/// <summary>
|
||||
/// Creates IdMessage with multiple parameters serialized directly as JSON.
|
||||
/// Each parameter is serialized independently without array wrapping.
|
||||
/// Use object[] explicitly to pass multiple parameters.
|
||||
/// </summary>
|
||||
public IdMessage(object[] ids)
|
||||
{
|
||||
//Ids.AddRange(ids);
|
||||
Ids.AddRange(ids.Select(x =>
|
||||
// Pre-allocate capacity to avoid list resizing
|
||||
Ids = new List<string>(ids.Length);
|
||||
for (var i = 0; i < ids.Length; i++)
|
||||
{
|
||||
string item;
|
||||
|
||||
//if (x is Expression expr)
|
||||
//{
|
||||
// string aa = string.Empty;
|
||||
// var serializer = new ExpressionSerializer(new JsonSerializer());
|
||||
// try
|
||||
// {
|
||||
// aa = serializer.SerializeText(expr);
|
||||
// }
|
||||
// catch(Exception ex)
|
||||
// {
|
||||
// Console.WriteLine(ex);
|
||||
// }
|
||||
|
||||
// item = (new[] { aa }).ToJson();
|
||||
//}
|
||||
//else
|
||||
item = (new[] { x }).ToJson();
|
||||
|
||||
return item;
|
||||
}));
|
||||
Ids.Add(ids[i].ToJson());
|
||||
}
|
||||
}
|
||||
|
||||
public IdMessage(object id) : this(new object[] { id })
|
||||
/// <summary>
|
||||
/// Creates IdMessage with a single parameter serialized as JSON.
|
||||
/// Collections (List, Array, etc.) are serialized as a single JSON array.
|
||||
/// </summary>
|
||||
public IdMessage(object id)
|
||||
{
|
||||
// Pre-allocate for single item
|
||||
Ids = new List<string>(1) { id.ToJson() };
|
||||
}
|
||||
|
||||
public IdMessage(IEnumerable<Guid> ids) : this(ids.Cast<object>().ToArray())
|
||||
/// <summary>
|
||||
/// Creates IdMessage with multiple Guid parameters.
|
||||
/// Each Guid is serialized as a separate Id entry.
|
||||
/// </summary>
|
||||
public IdMessage(IEnumerable<Guid> ids)
|
||||
{
|
||||
// Materialize to array once to get count and avoid multiple enumeration
|
||||
var idsArray = ids as Guid[] ?? ids.ToArray();
|
||||
Ids = new List<string>(idsArray.Length);
|
||||
for (var i = 0; i < idsArray.Length; i++)
|
||||
{
|
||||
Ids.Add(idsArray[i].ToJson());
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
|
|
@ -59,17 +63,18 @@ public class IdMessage
|
|||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class SignalPostJsonMessage
|
||||
{
|
||||
[Key(0)]
|
||||
public string PostDataJson { get; set; }
|
||||
public string PostDataJson { get; set; } = "";
|
||||
|
||||
public SignalPostJsonMessage()
|
||||
{}
|
||||
protected SignalPostJsonMessage(string postDataJson) => PostDataJson = postDataJson;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
[MessagePackObject(AllowPrivate = false)]
|
||||
public class SignalPostJsonDataMessage<TPostDataType> : SignalPostJsonMessage, ISignalPostMessage<TPostDataType> //where TPostDataType : class
|
||||
{
|
||||
[IgnoreMember]
|
||||
|
|
@ -144,16 +149,33 @@ public sealed class SignalResponseJsonMessage : ISignalResponseMessage<string>
|
|||
MessageTag = messageTag;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a response with the given data serialized as JSON.
|
||||
/// If responseData is already a JSON string (starts with { or [), it will be used directly.
|
||||
/// All other data types are serialized to JSON format.
|
||||
/// </summary>
|
||||
public SignalResponseJsonMessage(int messageTag, SignalResponseStatus status, object? responseData) : this(messageTag, status)
|
||||
{
|
||||
if (responseData is string stringdata)
|
||||
ResponseData = stringdata;
|
||||
else ResponseData = responseData.ToJson();
|
||||
}
|
||||
if (responseData == null)
|
||||
{
|
||||
ResponseData = null;
|
||||
return;
|
||||
}
|
||||
|
||||
public SignalResponseJsonMessage(int messageTag, SignalResponseStatus status, string? responseDataJson) : this(messageTag, status)
|
||||
{
|
||||
ResponseData = responseDataJson;
|
||||
// If responseData is already a JSON string, use it directly
|
||||
if (responseData is string strData)
|
||||
{
|
||||
var trimmed = strData.Trim();
|
||||
if (trimmed.Length > 1 && (trimmed[0] == '{' || trimmed[0] == '[') && (trimmed[^1] == '}' || trimmed[^1] == ']'))
|
||||
{
|
||||
// Already JSON - use directly without re-serialization
|
||||
ResponseData = strData;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize to JSON
|
||||
ResponseData = responseData.ToJson();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -162,7 +184,7 @@ public sealed class SignalResponseJsonMessage : ISignalResponseMessage<string>
|
|||
/// ResponseData is only deserialized on first access and cached.
|
||||
/// Use ResponseDataJson for direct JSON access without deserialization.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
[MessagePackObject(AllowPrivate = false)]
|
||||
public sealed class SignalResponseMessage<TResponseData> : ISignalResponseMessage<TResponseData>
|
||||
{
|
||||
[IgnoreMember]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
namespace AyCode.Services.SignalRs;
|
||||
using Microsoft.Extensions.ObjectPool;
|
||||
|
||||
public class SignalRRequestModel
|
||||
namespace AyCode.Services.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Request model for tracking pending SignalR requests.
|
||||
/// Poolable to reduce allocations in high-throughput scenarios.
|
||||
/// </summary>
|
||||
public class SignalRRequestModel : IResettable
|
||||
{
|
||||
public DateTime RequestDateTime;
|
||||
public DateTime ResponseDateTime;
|
||||
public object? ResponseByRequestId = null;
|
||||
public object? ResponseByRequestId;
|
||||
|
||||
public SignalRRequestModel()
|
||||
{
|
||||
|
|
@ -16,4 +22,54 @@ public class SignalRRequestModel
|
|||
ResponseByRequestId = responseByRequestId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the model for reuse from the pool.
|
||||
/// </summary>
|
||||
public bool TryReset()
|
||||
{
|
||||
RequestDateTime = DateTime.UtcNow;
|
||||
ResponseDateTime = default;
|
||||
ResponseByRequestId = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the model with a callback for reuse from the pool.
|
||||
/// </summary>
|
||||
public void Initialize(object? responseByRequestId = null)
|
||||
{
|
||||
RequestDateTime = DateTime.UtcNow;
|
||||
ResponseDateTime = default;
|
||||
ResponseByRequestId = responseByRequestId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Object pool for SignalRRequestModel to reduce allocations.
|
||||
/// Thread-safe and optimized for concurrent access.
|
||||
/// </summary>
|
||||
public static class SignalRRequestModelPool
|
||||
{
|
||||
private static readonly ObjectPool<SignalRRequestModel> Pool =
|
||||
new DefaultObjectPoolProvider().Create<SignalRRequestModel>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a SignalRRequestModel from the pool.
|
||||
/// </summary>
|
||||
public static SignalRRequestModel Get() => Pool.Get();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a SignalRRequestModel from the pool and initializes it with a callback.
|
||||
/// </summary>
|
||||
public static SignalRRequestModel Get(object responseByRequestId)
|
||||
{
|
||||
var model = Pool.Get();
|
||||
model.Initialize(responseByRequestId);
|
||||
return model;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a SignalRRequestModel to the pool for reuse.
|
||||
/// </summary>
|
||||
public static void Return(SignalRRequestModel model) => Pool.Return(model);
|
||||
}
|
||||
|
|
@ -14,6 +14,10 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Core.Tests\AyCode.Core.Tests.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Services\AyCode.Services.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Services.Server\AyCode.Services.Server.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Models.Server\AyCode.Models.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using BenchmarkDotNet.Running;
|
||||
using AyCode.Core.Benchmarks;
|
||||
|
||||
namespace BenchmarkSuite1
|
||||
{
|
||||
|
|
@ -13,7 +14,8 @@ namespace BenchmarkSuite1
|
|||
return;
|
||||
}
|
||||
|
||||
var _ = BenchmarkRunner.Run(typeof(Program).Assembly);
|
||||
// Use assembly-wide discovery for all benchmarks
|
||||
BenchmarkSwitcher.FromAssembly(typeof(SerializationBenchmarks).Assembly).Run(args);
|
||||
}
|
||||
|
||||
static void RunSizeComparison()
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Interfaces;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using Newtonsoft.Json;
|
||||
using System.Text;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// Serialization benchmarks comparing AcJsonSerializer/Deserializer with Newtonsoft.Json.
|
||||
/// Uses shared TestModels from AyCode.Core.Tests for consistency.
|
||||
/// </summary>
|
||||
[MemoryDiagnoser]
|
||||
public class SerializationBenchmarks
|
||||
{
|
||||
// Complex graph with 7 levels, ~1500 objects, cross-references
|
||||
private Level1_Company _complexGraph = null!;
|
||||
// Test data - uses shared TestModels
|
||||
private TestOrder _testOrder = null!;
|
||||
|
||||
// Pre-serialized JSON for deserialization benchmarks
|
||||
private string _newtonsoftJson = null!;
|
||||
private string _ayCodeJson = null!;
|
||||
|
||||
// Target objects for Populate benchmarks
|
||||
private TestOrder _populateTarget = null!;
|
||||
|
||||
// Settings
|
||||
private JsonSerializerSettings _newtonsoftNoRefSettings = null!;
|
||||
private JsonSerializerSettings _ayCodeSettings = null!;
|
||||
|
|
@ -28,29 +35,36 @@ public class SerializationBenchmarks
|
|||
{
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
DefaultValueHandling = DefaultValueHandling.Ignore, // Fair comparison - also skip defaults
|
||||
DefaultValueHandling = DefaultValueHandling.Ignore,
|
||||
Formatting = Formatting.None
|
||||
};
|
||||
|
||||
// AyCode WITH reference handling (our optimized solution)
|
||||
// AyCode WITH reference handling
|
||||
_ayCodeSettings = SerializeObjectExtensions.Options;
|
||||
|
||||
// Create complex 7-level graph with ~1500 objects and cross-references
|
||||
_complexGraph = CreateComplexGraph();
|
||||
// Create benchmark data using shared factory
|
||||
// ~1500 objects: 5 items × 4 pallets × 3 measurements × 5 points = 300 points + containers
|
||||
_testOrder = TestDataFactory.CreateBenchmarkOrder(
|
||||
itemCount: 5,
|
||||
palletsPerItem: 4,
|
||||
measurementsPerPallet: 3,
|
||||
pointsPerMeasurement: 5);
|
||||
|
||||
// Pre-serialize for deserialization benchmarks
|
||||
_newtonsoftJson = JsonConvert.SerializeObject(_complexGraph, _newtonsoftNoRefSettings);
|
||||
_ayCodeJson = _complexGraph.ToJson(_ayCodeSettings);
|
||||
_newtonsoftJson = JsonConvert.SerializeObject(_testOrder, _newtonsoftNoRefSettings);
|
||||
_ayCodeJson = _testOrder.ToJson(_ayCodeSettings);
|
||||
|
||||
// Create target for populate benchmarks
|
||||
_populateTarget = new TestOrder();
|
||||
|
||||
// Output sizes for comparison
|
||||
var newtonsoftBytes = Encoding.UTF8.GetByteCount(_newtonsoftJson);
|
||||
var ayCodeBytes = Encoding.UTF8.GetByteCount(_ayCodeJson);
|
||||
|
||||
Console.WriteLine("=== JSON Size Comparison ===");
|
||||
Console.WriteLine($"Newtonsoft (skip defaults): {_newtonsoftJson.Length:N0} chars ({newtonsoftBytes:N0} bytes, {newtonsoftBytes / 1024.0:F1} KB)");
|
||||
Console.WriteLine($"AcJsonSerializer (refs+skip): {_ayCodeJson.Length:N0} chars ({ayCodeBytes:N0} bytes, {ayCodeBytes / 1024.0:F1} KB)");
|
||||
Console.WriteLine($"Newtonsoft (skip defaults): {_newtonsoftJson.Length:N0} chars ({newtonsoftBytes:N0} bytes)");
|
||||
Console.WriteLine($"AyCode (refs+skip): {_ayCodeJson.Length:N0} chars ({ayCodeBytes:N0} bytes)");
|
||||
Console.WriteLine($"Size reduction: {(1.0 - (double)ayCodeBytes / newtonsoftBytes) * 100:F1}%");
|
||||
Console.WriteLine($"Bytes saved: {newtonsoftBytes - ayCodeBytes:N0}");
|
||||
}
|
||||
|
||||
#region Serialization Benchmarks
|
||||
|
|
@ -58,12 +72,17 @@ public class SerializationBenchmarks
|
|||
[Benchmark(Description = "Newtonsoft (no refs)")]
|
||||
[BenchmarkCategory("Serialize")]
|
||||
public string Serialize_Newtonsoft_NoRefs()
|
||||
=> JsonConvert.SerializeObject(_complexGraph, _newtonsoftNoRefSettings);
|
||||
=> JsonConvert.SerializeObject(_testOrder, _newtonsoftNoRefSettings);
|
||||
|
||||
[Benchmark(Description = "AyCode (with refs)")]
|
||||
[BenchmarkCategory("Serialize")]
|
||||
public string Serialize_AyCode_WithRefs()
|
||||
=> _complexGraph.ToJson(_ayCodeSettings);
|
||||
=> _testOrder.ToJson(_ayCodeSettings);
|
||||
|
||||
[Benchmark(Description = "AcJsonSerializer (custom)")]
|
||||
[BenchmarkCategory("Serialize")]
|
||||
public string Serialize_AcJsonSerializer()
|
||||
=> AcJsonSerializer.Serialize(_testOrder);
|
||||
|
||||
#endregion
|
||||
|
||||
|
|
@ -71,22 +90,44 @@ public class SerializationBenchmarks
|
|||
|
||||
[Benchmark(Description = "Newtonsoft (no refs)")]
|
||||
[BenchmarkCategory("Deserialize")]
|
||||
public Level1_Company? Deserialize_Newtonsoft_NoRefs()
|
||||
=> JsonConvert.DeserializeObject<Level1_Company>(_newtonsoftJson, _newtonsoftNoRefSettings);
|
||||
public TestOrder? Deserialize_Newtonsoft_NoRefs()
|
||||
=> JsonConvert.DeserializeObject<TestOrder>(_newtonsoftJson, _newtonsoftNoRefSettings);
|
||||
|
||||
[Benchmark(Description = "AyCode (with refs)")]
|
||||
[BenchmarkCategory("Deserialize")]
|
||||
public Level1_Company? Deserialize_AyCode_WithRefs()
|
||||
=> _ayCodeJson.JsonTo<Level1_Company>(_ayCodeSettings);
|
||||
public TestOrder? Deserialize_AyCode_WithRefs()
|
||||
=> _ayCodeJson.JsonTo<TestOrder>(_ayCodeSettings);
|
||||
|
||||
[Benchmark(Description = "AcJsonDeserializer (custom)")]
|
||||
[BenchmarkCategory("Deserialize")]
|
||||
public Level1_Company? Deserialize_AcJsonDeserializer()
|
||||
=> AcJsonDeserializer.Deserialize<Level1_Company>(_ayCodeJson);
|
||||
public TestOrder? Deserialize_AcJsonDeserializer()
|
||||
=> AcJsonDeserializer.Deserialize<TestOrder>(_ayCodeJson);
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Size Comparison (not timed, just for reporting)
|
||||
#region Populate Benchmarks
|
||||
|
||||
[Benchmark(Description = "AcJsonDeserializer.Populate")]
|
||||
[BenchmarkCategory("Populate")]
|
||||
public void Populate_AcJsonDeserializer()
|
||||
{
|
||||
// Create fresh target for each iteration to avoid state pollution
|
||||
var target = new TestOrder();
|
||||
AcJsonDeserializer.Populate(_ayCodeJson, target);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Newtonsoft PopulateObject")]
|
||||
[BenchmarkCategory("Populate")]
|
||||
public void Populate_Newtonsoft()
|
||||
{
|
||||
// Create fresh target for each iteration to match the other benchmark
|
||||
var target = new TestOrder();
|
||||
JsonConvert.PopulateObject(_newtonsoftJson, target, _newtonsoftNoRefSettings);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Size Comparison
|
||||
|
||||
[Benchmark(Description = "JSON Size - Newtonsoft")]
|
||||
[BenchmarkCategory("Size")]
|
||||
|
|
@ -97,420 +138,4 @@ public class SerializationBenchmarks
|
|||
public int JsonSize_AyCode() => _ayCodeJson.Length;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complex Graph Factory - 7 Levels, ~1500 objects, Cross-references
|
||||
|
||||
private static int _idCounter = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 7-level deep graph with approximately 1500 objects and cross-references.
|
||||
/// Structure: Company -> Departments -> Teams -> Projects -> Tasks -> SubTasks -> Comments
|
||||
/// Each object has 8-15 properties of various types.
|
||||
/// </summary>
|
||||
private static Level1_Company CreateComplexGraph()
|
||||
{
|
||||
_idCounter = 1;
|
||||
|
||||
// Shared references (cross-references across the graph)
|
||||
var sharedTags = Enumerable.Range(1, 10)
|
||||
.Select(i => new SharedTag
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Name = $"Tag-{i}",
|
||||
Color = $"#{i:X2}{i * 10:X2}{i * 20:X2}",
|
||||
Priority = i % 5,
|
||||
IsActive = i % 2 == 0,
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-i * 10),
|
||||
Metadata = $"Metadata for tag {i}"
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var sharedCategories = Enumerable.Range(1, 5)
|
||||
.Select(i => new SharedCategory
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Name = $"Category-{i}",
|
||||
Description = $"Description for category {i} with some extra text to make it longer",
|
||||
SortOrder = i * 100,
|
||||
IconUrl = $"https://icons.example.com/cat-{i}.png",
|
||||
IsDefault = i == 1,
|
||||
ParentCategoryId = i > 1 ? i - 1 : null,
|
||||
CreatedAt = DateTime.UtcNow.AddMonths(-i),
|
||||
UpdatedAt = DateTime.UtcNow.AddDays(-i)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var sharedUser = new SharedUser
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Username = "admin",
|
||||
Email = "admin@company.com",
|
||||
FirstName = "System",
|
||||
LastName = "Administrator",
|
||||
PhoneNumber = "+1-555-0100",
|
||||
IsActive = true,
|
||||
Role = UserRole.Admin,
|
||||
LastLoginAt = DateTime.UtcNow.AddHours(-1),
|
||||
CreatedAt = DateTime.UtcNow.AddYears(-2),
|
||||
Preferences = new UserPreferences
|
||||
{
|
||||
Theme = "dark",
|
||||
Language = "en-US",
|
||||
NotificationsEnabled = true,
|
||||
EmailDigestFrequency = "daily"
|
||||
}
|
||||
};
|
||||
|
||||
// Level 1: Company (1 object)
|
||||
var company = new Level1_Company
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Name = "TechCorp International",
|
||||
LegalName = "TechCorp International Holdings Ltd.",
|
||||
TaxId = "TC-123456789",
|
||||
FoundedDate = new DateTime(2010, 3, 15),
|
||||
EmployeeCount = 1500,
|
||||
AnnualRevenue = 125_000_000.50m,
|
||||
IsPubliclyTraded = true,
|
||||
StockSymbol = "TECH",
|
||||
HeadquartersAddress = "123 Innovation Drive, Tech City, TC 12345",
|
||||
Website = "https://www.techcorp.example.com",
|
||||
PrimaryContact = sharedUser,
|
||||
MainCategory = sharedCategories[0],
|
||||
Tags = [sharedTags[0], sharedTags[1], sharedTags[2]],
|
||||
CreatedAt = DateTime.UtcNow.AddYears(-5),
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Level 2: Departments (5 objects)
|
||||
company.Departments = Enumerable.Range(1, 5).Select(deptIdx => new Level2_Department
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Name = $"Department-{deptIdx}",
|
||||
Code = $"DEPT-{deptIdx:D3}",
|
||||
Description = $"This is department {deptIdx} responsible for various operations and strategic initiatives",
|
||||
Budget = 1_000_000m + (deptIdx * 250_000m),
|
||||
HeadCount = 50 + (deptIdx * 20),
|
||||
Location = $"Building {(char)('A' + deptIdx - 1)}, Floor {deptIdx}",
|
||||
CostCenter = $"CC-{1000 + deptIdx}",
|
||||
IsActive = true,
|
||||
Manager = sharedUser, // Cross-reference
|
||||
Category = sharedCategories[deptIdx % sharedCategories.Count], // Cross-reference
|
||||
Tags = [sharedTags[deptIdx % sharedTags.Count], sharedTags[(deptIdx + 1) % sharedTags.Count]], // Cross-reference
|
||||
EstablishedDate = DateTime.UtcNow.AddYears(-4).AddMonths(deptIdx),
|
||||
CreatedAt = DateTime.UtcNow.AddYears(-4),
|
||||
UpdatedAt = DateTime.UtcNow.AddMonths(-deptIdx),
|
||||
// Level 3: Teams (6 per department = 30 total)
|
||||
Teams = Enumerable.Range(1, 6).Select(teamIdx => new Level3_Team
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Name = $"Team-{deptIdx}-{teamIdx}",
|
||||
Acronym = $"T{deptIdx}{teamIdx}",
|
||||
Description = $"Team {teamIdx} in department {deptIdx}, focused on delivering excellence",
|
||||
MemberCount = 5 + (teamIdx * 2),
|
||||
Capacity = 10 + (teamIdx * 2),
|
||||
Utilization = 0.65 + (teamIdx * 0.05),
|
||||
SprintLength = 14,
|
||||
VelocityAverage = 42.5 + teamIdx,
|
||||
IsRemote = teamIdx % 3 == 0,
|
||||
Timezone = teamIdx % 2 == 0 ? "UTC" : "America/New_York",
|
||||
SlackChannel = $"#team-{deptIdx}-{teamIdx}",
|
||||
TeamLead = sharedUser, // Cross-reference
|
||||
PrimaryTag = sharedTags[(deptIdx + teamIdx) % sharedTags.Count], // Cross-reference
|
||||
CreatedAt = DateTime.UtcNow.AddYears(-3).AddMonths(teamIdx),
|
||||
UpdatedAt = DateTime.UtcNow.AddDays(-teamIdx * 7),
|
||||
// Level 4: Projects (4 per team = 120 total)
|
||||
Projects = Enumerable.Range(1, 4).Select(projIdx => new Level4_Project
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Name = $"Project-{deptIdx}-{teamIdx}-{projIdx}",
|
||||
Code = $"PRJ-{deptIdx}{teamIdx}{projIdx:D2}",
|
||||
Description = $"Project {projIdx} for team {teamIdx}, delivering key business value and innovation",
|
||||
Status = (ProjectStatus)(projIdx % 4),
|
||||
Priority = (Priority)(projIdx % 3),
|
||||
Budget = 50_000m + (projIdx * 15_000m),
|
||||
SpentAmount = 25_000m + (projIdx * 5_000m),
|
||||
ProgressPercent = 0.1 + (projIdx * 0.2),
|
||||
StartDate = DateTime.UtcNow.AddMonths(-projIdx * 2),
|
||||
DueDate = DateTime.UtcNow.AddMonths(projIdx),
|
||||
CompletedDate = projIdx == 4 ? DateTime.UtcNow.AddDays(-10) : null,
|
||||
EstimatedHours = 200 + (projIdx * 50),
|
||||
ActualHours = 150 + (projIdx * 40),
|
||||
RiskLevel = projIdx % 3,
|
||||
Owner = sharedUser, // Cross-reference
|
||||
Category = sharedCategories[projIdx % sharedCategories.Count], // Cross-reference
|
||||
Tags = [sharedTags[projIdx % sharedTags.Count]], // Cross-reference
|
||||
CreatedAt = DateTime.UtcNow.AddMonths(-projIdx * 3),
|
||||
UpdatedAt = DateTime.UtcNow.AddDays(-projIdx),
|
||||
// Level 5: Tasks (5 per project = 600 total)
|
||||
Tasks = Enumerable.Range(1, 5).Select(taskIdx => new Level5_Task
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Title = $"Task-{deptIdx}-{teamIdx}-{projIdx}-{taskIdx}",
|
||||
Description = $"Detailed task description for task {taskIdx} in project {projIdx}. This includes requirements and acceptance criteria.",
|
||||
Status = (TaskStatus)(taskIdx % 5),
|
||||
Priority = (Priority)(taskIdx % 3),
|
||||
Type = (TaskType)(taskIdx % 4),
|
||||
StoryPoints = taskIdx * 2,
|
||||
EstimatedHours = 4 + taskIdx * 2,
|
||||
ActualHours = 3 + taskIdx * 1.5,
|
||||
DueDate = DateTime.UtcNow.AddDays(taskIdx * 3),
|
||||
CompletedDate = taskIdx <= 2 ? DateTime.UtcNow.AddDays(-taskIdx) : null,
|
||||
IsBlocked = taskIdx == 3,
|
||||
BlockedReason = taskIdx == 3 ? "Waiting for external dependency" : null,
|
||||
Assignee = sharedUser, // Cross-reference
|
||||
Reporter = sharedUser, // Cross-reference
|
||||
Labels = [sharedTags[taskIdx % sharedTags.Count]], // Cross-reference
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-taskIdx * 5),
|
||||
UpdatedAt = DateTime.UtcNow.AddHours(-taskIdx),
|
||||
// Level 6: SubTasks (3 per task = 1800 total -> we'll limit to keep ~1500)
|
||||
SubTasks = Enumerable.Range(1, 2).Select(subIdx => new Level6_SubTask
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Title = $"SubTask-{taskIdx}-{subIdx}",
|
||||
Description = $"Sub-task {subIdx} details for completing parent task {taskIdx}",
|
||||
Status = (TaskStatus)(subIdx % 5),
|
||||
EstimatedMinutes = 30 + subIdx * 15,
|
||||
ActualMinutes = 25 + subIdx * 12,
|
||||
IsCompleted = subIdx == 1,
|
||||
CompletedAt = subIdx == 1 ? DateTime.UtcNow.AddHours(-subIdx * 2) : null,
|
||||
Assignee = sharedUser, // Cross-reference
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-subIdx),
|
||||
UpdatedAt = DateTime.UtcNow.AddMinutes(-subIdx * 30),
|
||||
// Level 7: Comments (2 per subtask = 2400 total -> limiting)
|
||||
Comments = Enumerable.Range(1, 1).Select(comIdx => new Level7_Comment
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Text = $"Comment {comIdx} on subtask {subIdx}: This is a detailed comment with feedback and suggestions for improvement.",
|
||||
Author = sharedUser, // Cross-reference
|
||||
IsEdited = comIdx % 2 == 0,
|
||||
EditedAt = comIdx % 2 == 0 ? DateTime.UtcNow.AddHours(-1) : null,
|
||||
LikeCount = comIdx * 3,
|
||||
ReplyCount = comIdx,
|
||||
CreatedAt = DateTime.UtcNow.AddHours(-comIdx * 4),
|
||||
MentionedTags = [sharedTags[comIdx % sharedTags.Count]] // Cross-reference
|
||||
}).ToList()
|
||||
}).ToList()
|
||||
}).ToList()
|
||||
}).ToList()
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
return company;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 7-Level Deep DTOs with 8-15 Properties Each
|
||||
|
||||
// Shared cross-reference types
|
||||
public class SharedTag : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Color { get; set; } = "";
|
||||
public int Priority { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public string Metadata { get; set; } = "";
|
||||
}
|
||||
|
||||
public class SharedCategory : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public int SortOrder { get; set; }
|
||||
public string IconUrl { get; set; } = "";
|
||||
public bool IsDefault { get; set; }
|
||||
public int? ParentCategoryId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class SharedUser : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Username { get; set; } = "";
|
||||
public string Email { get; set; } = "";
|
||||
public string FirstName { get; set; } = "";
|
||||
public string LastName { get; set; } = "";
|
||||
public string PhoneNumber { get; set; } = "";
|
||||
public bool IsActive { get; set; }
|
||||
public UserRole Role { get; set; }
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public UserPreferences? Preferences { get; set; }
|
||||
}
|
||||
|
||||
public class UserPreferences
|
||||
{
|
||||
public string Theme { get; set; } = "";
|
||||
public string Language { get; set; } = "";
|
||||
public bool NotificationsEnabled { get; set; }
|
||||
public string EmailDigestFrequency { get; set; } = "";
|
||||
}
|
||||
|
||||
public enum UserRole { User, Manager, Admin }
|
||||
public enum ProjectStatus { Planning, Active, OnHold, Completed }
|
||||
public enum TaskStatus { Backlog, Todo, InProgress, Review, Done }
|
||||
public enum TaskType { Feature, Bug, Improvement, Task }
|
||||
public enum Priority { Low, Medium, High }
|
||||
|
||||
// Level 1: Company (15 properties)
|
||||
public class Level1_Company : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string LegalName { get; set; } = "";
|
||||
public string TaxId { get; set; } = "";
|
||||
public DateTime FoundedDate { get; set; }
|
||||
public int EmployeeCount { get; set; }
|
||||
public decimal AnnualRevenue { get; set; }
|
||||
public bool IsPubliclyTraded { get; set; }
|
||||
public string? StockSymbol { get; set; }
|
||||
public string HeadquartersAddress { get; set; } = "";
|
||||
public string Website { get; set; } = "";
|
||||
public SharedUser? PrimaryContact { get; set; } // Cross-ref
|
||||
public SharedCategory? MainCategory { get; set; } // Cross-ref
|
||||
public List<SharedTag> Tags { get; set; } = []; // Cross-ref
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public List<Level2_Department> Departments { get; set; } = [];
|
||||
}
|
||||
|
||||
// Level 2: Department (15 properties)
|
||||
public class Level2_Department : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Code { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public decimal Budget { get; set; }
|
||||
public int HeadCount { get; set; }
|
||||
public string Location { get; set; } = "";
|
||||
public string CostCenter { get; set; } = "";
|
||||
public bool IsActive { get; set; }
|
||||
public SharedUser? Manager { get; set; } // Cross-ref
|
||||
public SharedCategory? Category { get; set; } // Cross-ref
|
||||
public List<SharedTag> Tags { get; set; } = []; // Cross-ref
|
||||
public DateTime EstablishedDate { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public List<Level3_Team> Teams { get; set; } = [];
|
||||
}
|
||||
|
||||
// Level 3: Team (15 properties)
|
||||
public class Level3_Team : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Acronym { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public int MemberCount { get; set; }
|
||||
public int Capacity { get; set; }
|
||||
public double Utilization { get; set; }
|
||||
public int SprintLength { get; set; }
|
||||
public double VelocityAverage { get; set; }
|
||||
public bool IsRemote { get; set; }
|
||||
public string Timezone { get; set; } = "";
|
||||
public string SlackChannel { get; set; } = "";
|
||||
public SharedUser? TeamLead { get; set; } // Cross-ref
|
||||
public SharedTag? PrimaryTag { get; set; } // Cross-ref
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public List<Level4_Project> Projects { get; set; } = [];
|
||||
}
|
||||
|
||||
// Level 4: Project (18 properties)
|
||||
public class Level4_Project : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Code { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public ProjectStatus Status { get; set; }
|
||||
public Priority Priority { get; set; }
|
||||
public decimal Budget { get; set; }
|
||||
public decimal SpentAmount { get; set; }
|
||||
public double ProgressPercent { get; set; }
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime DueDate { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public int EstimatedHours { get; set; }
|
||||
public int ActualHours { get; set; }
|
||||
public int RiskLevel { get; set; }
|
||||
public SharedUser? Owner { get; set; } // Cross-ref
|
||||
public SharedCategory? Category { get; set; } // Cross-ref
|
||||
public List<SharedTag> Tags { get; set; } = []; // Cross-ref
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public List<Level5_Task> Tasks { get; set; } = [];
|
||||
}
|
||||
|
||||
// Level 5: Task (18 properties)
|
||||
public class Level5_Task : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Title { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public TaskStatus Status { get; set; }
|
||||
public Priority Priority { get; set; }
|
||||
public TaskType Type { get; set; }
|
||||
public int StoryPoints { get; set; }
|
||||
public double EstimatedHours { get; set; }
|
||||
public double ActualHours { get; set; }
|
||||
public DateTime DueDate { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public bool IsBlocked { get; set; }
|
||||
public string? BlockedReason { get; set; }
|
||||
public SharedUser? Assignee { get; set; } // Cross-ref
|
||||
public SharedUser? Reporter { get; set; } // Cross-ref
|
||||
public List<SharedTag> Labels { get; set; } = []; // Cross-ref
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public List<Level6_SubTask> SubTasks { get; set; } = [];
|
||||
}
|
||||
|
||||
// Level 6: SubTask (11 properties)
|
||||
public class Level6_SubTask : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Title { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public TaskStatus Status { get; set; }
|
||||
public int EstimatedMinutes { get; set; }
|
||||
public int ActualMinutes { get; set; }
|
||||
public bool IsCompleted { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public SharedUser? Assignee { get; set; } // Cross-ref
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public List<Level7_Comment> Comments { get; set; } = [];
|
||||
}
|
||||
|
||||
// Level 7: Comment (10 properties)
|
||||
public class Level7_Comment : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Text { get; set; } = "";
|
||||
public SharedUser? Author { get; set; } // Cross-ref
|
||||
public bool IsEdited { get; set; }
|
||||
public DateTime? EditedAt { get; set; }
|
||||
public int LikeCount { get; set; }
|
||||
public int ReplyCount { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public List<SharedTag> MentionedTags { get; set; } = []; // Cross-ref
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AcJsonSerializer Benchmarks
|
||||
|
||||
[Benchmark(Description = "AcJsonSerializer (custom)")]
|
||||
[BenchmarkCategory("Serialize")]
|
||||
public string Serialize_AcJsonSerializer()
|
||||
=> AcJsonSerializer.Serialize(_complexGraph);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using MessagePack;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR communication benchmarks measuring the full serialization workflow:
|
||||
/// Client ? IdMessage ? MessagePack ? Server ? Deserialize ? Response ? MessagePack ? Client
|
||||
/// </summary>
|
||||
[MemoryDiagnoser]
|
||||
public class SignalRCommunicationBenchmarks
|
||||
{
|
||||
// Shared test data
|
||||
private SignalRBenchmarkData _data = null !;
|
||||
// Pre-serialized messages for deserialization benchmarks
|
||||
private byte[] _singleIntMessage = null !;
|
||||
private byte[] _twoIntMessage = null !;
|
||||
private byte[] _fiveParamsMessage = null !;
|
||||
private byte[] _complexOrderItemMessage = null !;
|
||||
private byte[] _complexOrderMessage = null !;
|
||||
private byte[] _intArrayMessage = null !;
|
||||
private byte[] _mixedParamsMessage = null !;
|
||||
// Pre-serialized response for client-side deserialization
|
||||
private byte[] _successResponseMessage = null !;
|
||||
private byte[] _complexResponseMessage = null !;
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
_data = new SignalRBenchmarkData();
|
||||
// Copy pre-serialized messages
|
||||
_singleIntMessage = _data.SingleIntMessage;
|
||||
_twoIntMessage = _data.TwoIntMessage;
|
||||
_fiveParamsMessage = _data.FiveParamsMessage;
|
||||
_complexOrderItemMessage = _data.ComplexOrderItemMessage;
|
||||
_complexOrderMessage = _data.ComplexOrderMessage;
|
||||
_intArrayMessage = _data.IntArrayMessage;
|
||||
_mixedParamsMessage = _data.MixedParamsMessage;
|
||||
// Pre-serialize response messages
|
||||
_successResponseMessage = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.SingleIntParam, "Received: 42");
|
||||
_complexResponseMessage = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderParam, _data.TestOrder);
|
||||
Console.WriteLine("=== SignalR Message Size Comparison ===");
|
||||
Console.WriteLine($"Single int message: {_singleIntMessage.Length} bytes");
|
||||
Console.WriteLine($"Two int message: {_twoIntMessage.Length} bytes");
|
||||
Console.WriteLine($"Five params message: {_fiveParamsMessage.Length} bytes");
|
||||
Console.WriteLine($"Complex OrderItem message: {_complexOrderItemMessage.Length} bytes");
|
||||
Console.WriteLine($"Complex Order message: {_complexOrderMessage.Length} bytes");
|
||||
Console.WriteLine($"Int array message: {_intArrayMessage.Length} bytes");
|
||||
Console.WriteLine($"Mixed params message: {_mixedParamsMessage.Length} bytes");
|
||||
Console.WriteLine($"Success response: {_successResponseMessage.Length} bytes");
|
||||
Console.WriteLine($"Complex response: {_complexResponseMessage.Length} bytes");
|
||||
}
|
||||
|
||||
#region Client-Side: Message Creation (IdMessage + MessagePack Serialization)
|
||||
[Benchmark(Description = "Client: Create single int message")]
|
||||
[BenchmarkCategory("Client", "Create")]
|
||||
public byte[] Client_CreateSingleIntMessage() => SignalRMessageFactory.CreateSingleParamMessage(42);
|
||||
[Benchmark(Description = "Client: Create two int message")]
|
||||
[BenchmarkCategory("Client", "Create")]
|
||||
public byte[] Client_CreateTwoIntMessage() => SignalRMessageFactory.CreateIdMessage(10, 20);
|
||||
[Benchmark(Description = "Client: Create five params message")]
|
||||
[BenchmarkCategory("Client", "Create")]
|
||||
public byte[] Client_CreateFiveParamsMessage() => SignalRMessageFactory.CreateIdMessage(42, "hello", true, _data.TestGuid, 99.99m);
|
||||
[Benchmark(Description = "Client: Create complex OrderItem message")]
|
||||
[BenchmarkCategory("Client", "Create")]
|
||||
public byte[] Client_CreateComplexOrderItemMessage() => SignalRMessageFactory.CreateComplexObjectMessage(_data.TestOrderItem);
|
||||
[Benchmark(Description = "Client: Create complex Order message")]
|
||||
[BenchmarkCategory("Client", "Create")]
|
||||
public byte[] Client_CreateComplexOrderMessage() => SignalRMessageFactory.CreateComplexObjectMessage(_data.TestOrder);
|
||||
#endregion
|
||||
#region Server-Side: Message Deserialization (MessagePack + JSON)
|
||||
[Benchmark(Description = "Server: Deserialize single int")]
|
||||
[BenchmarkCategory("Server", "Deserialize")]
|
||||
public int Server_DeserializeSingleInt()
|
||||
{
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_singleIntMessage, SignalRMessageFactory.ContractlessOptions);
|
||||
var idMessage = postMessage.PostDataJson!.JsonTo<SignalRIdMessageDto>()!;
|
||||
return AcJsonDeserializer.Deserialize<int>(idMessage.Ids[0]);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Server: Deserialize two ints")]
|
||||
[BenchmarkCategory("Server", "Deserialize")]
|
||||
public (int, int) Server_DeserializeTwoInts()
|
||||
{
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_twoIntMessage, SignalRMessageFactory.ContractlessOptions);
|
||||
var idMessage = postMessage.PostDataJson!.JsonTo<SignalRIdMessageDto>()!;
|
||||
var a = AcJsonDeserializer.Deserialize<int>(idMessage.Ids[0]);
|
||||
var b = AcJsonDeserializer.Deserialize<int>(idMessage.Ids[1]);
|
||||
return (a, b);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Server: Deserialize five params")]
|
||||
[BenchmarkCategory("Server", "Deserialize")]
|
||||
public (int, string, bool, Guid, decimal) Server_DeserializeFiveParams()
|
||||
{
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_fiveParamsMessage, SignalRMessageFactory.ContractlessOptions);
|
||||
var idMessage = postMessage.PostDataJson!.JsonTo<SignalRIdMessageDto>()!;
|
||||
var a = AcJsonDeserializer.Deserialize<int>(idMessage.Ids[0]);
|
||||
var b = AcJsonDeserializer.Deserialize<string>(idMessage.Ids[1])!;
|
||||
var c = AcJsonDeserializer.Deserialize<bool>(idMessage.Ids[2]);
|
||||
var d = AcJsonDeserializer.Deserialize<Guid>(idMessage.Ids[3]);
|
||||
var e = AcJsonDeserializer.Deserialize<decimal>(idMessage.Ids[4]);
|
||||
return (a, b, c, d, e);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Server: Deserialize complex OrderItem")]
|
||||
[BenchmarkCategory("Server", "Deserialize")]
|
||||
public TestOrderItem Server_DeserializeComplexOrderItem()
|
||||
{
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_complexOrderItemMessage, SignalRMessageFactory.StandardOptions);
|
||||
return postMessage.PostDataJson!.JsonTo<TestOrderItem>()!;
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Server: Deserialize complex Order")]
|
||||
[BenchmarkCategory("Server", "Deserialize")]
|
||||
public TestOrder Server_DeserializeComplexOrder()
|
||||
{
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_complexOrderMessage, SignalRMessageFactory.StandardOptions);
|
||||
return postMessage.PostDataJson!.JsonTo<TestOrder>()!;
|
||||
}
|
||||
|
||||
#endregion
|
||||
#region Server-Side: Response Creation (JSON + MessagePack Serialization)
|
||||
[Benchmark(Description = "Server: Create success response (string)")]
|
||||
[BenchmarkCategory("Server", "Response")]
|
||||
public byte[] Server_CreateSuccessStringResponse() => SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.SingleIntParam, "Received: 42");
|
||||
[Benchmark(Description = "Server: Create success response (OrderItem)")]
|
||||
[BenchmarkCategory("Server", "Response")]
|
||||
public byte[] Server_CreateSuccessOrderItemResponse() => SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderItemParam, _data.TestOrderItem);
|
||||
[Benchmark(Description = "Server: Create success response (Order)")]
|
||||
[BenchmarkCategory("Server", "Response")]
|
||||
public byte[] Server_CreateSuccessOrderResponse() => SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderParam, _data.TestOrder);
|
||||
#endregion
|
||||
#region Client-Side: Response Deserialization
|
||||
[Benchmark(Description = "Client: Deserialize string response")]
|
||||
[BenchmarkCategory("Client", "Response")]
|
||||
public string? Client_DeserializeStringResponse()
|
||||
{
|
||||
var response = SignalRMessageFactory.DeserializeResponse(_successResponseMessage);
|
||||
return response?.ResponseData;
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Client: Deserialize complex Order response")]
|
||||
[BenchmarkCategory("Client", "Response")]
|
||||
public TestOrder? Client_DeserializeOrderResponse()
|
||||
{
|
||||
var response = SignalRMessageFactory.DeserializeResponse(_complexResponseMessage);
|
||||
return response?.ResponseData?.JsonTo<TestOrder>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
#region Full Round-Trip Benchmarks
|
||||
[Benchmark(Description = "Full: Single int round-trip")]
|
||||
[BenchmarkCategory("Full")]
|
||||
public string? Full_SingleIntRoundTrip()
|
||||
{
|
||||
// Client creates message
|
||||
var requestBytes = SignalRMessageFactory.CreateSingleParamMessage(42);
|
||||
// Server deserializes
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(requestBytes, SignalRMessageFactory.ContractlessOptions);
|
||||
var idMessage = postMessage.PostDataJson!.JsonTo<SignalRIdMessageDto>()!;
|
||||
var value = AcJsonDeserializer.Deserialize<int>(idMessage.Ids[0]);
|
||||
// Server creates response
|
||||
var responseBytes = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.SingleIntParam, $"Received: {value}");
|
||||
// Client deserializes response
|
||||
var response = SignalRMessageFactory.DeserializeResponse(responseBytes);
|
||||
return response?.ResponseData;
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Full: Complex Order round-trip")]
|
||||
[BenchmarkCategory("Full")]
|
||||
public TestOrder? Full_ComplexOrderRoundTrip()
|
||||
{
|
||||
// Client creates message
|
||||
var requestBytes = SignalRMessageFactory.CreateComplexObjectMessage(_data.TestOrder);
|
||||
// Server deserializes
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(requestBytes, SignalRMessageFactory.StandardOptions);
|
||||
var order = postMessage.PostDataJson!.JsonTo<TestOrder>()!;
|
||||
// Server modifies and creates response
|
||||
order.OrderNumber = "PROCESSED-" + order.OrderNumber;
|
||||
var responseBytes = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderParam, order);
|
||||
// Client deserializes response
|
||||
var response = SignalRMessageFactory.DeserializeResponse(responseBytes);
|
||||
return response?.ResponseData?.JsonTo<TestOrder>();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
using System.Security.Claims;
|
||||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Models.Server.DynamicMethods;
|
||||
using AyCode.Services.Server.SignalRs;
|
||||
using AyCode.Services.SignalRs;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.VSDiagnostics;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks for SignalR round-trip communication using the same infrastructure as SignalRClientToHubTest.
|
||||
/// Measures: Client -> Server -> Service -> Response -> Client
|
||||
/// </summary>
|
||||
[MemoryDiagnoser]
|
||||
[CPUUsageDiagnoser]
|
||||
public class SignalRRoundTripBenchmarks
|
||||
{
|
||||
private BenchmarkSignalRClient _client = null!;
|
||||
private BenchmarkSignalRHub _hub = null!;
|
||||
private BenchmarkSignalRService _service = null!;
|
||||
|
||||
// Pre-created test data
|
||||
private TestOrderItem _testOrderItem = null!;
|
||||
private TestOrder _testOrder = null!;
|
||||
private SharedTag _sharedTag = null!;
|
||||
private int[] _intArray = null!;
|
||||
private List<string> _stringList = null!;
|
||||
private Guid _testGuid;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
var logger = new TestLogger();
|
||||
_hub = new BenchmarkSignalRHub(logger);
|
||||
_service = new BenchmarkSignalRService();
|
||||
_client = new BenchmarkSignalRClient(_hub, logger);
|
||||
_hub.RegisterService(_service, _client);
|
||||
|
||||
// Pre-create test data
|
||||
_testOrderItem = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10.50m };
|
||||
_testOrder = TestDataFactory.CreateOrder(itemCount: 3);
|
||||
_sharedTag = new SharedTag { Id = 1, Name = "Important", Color = "#FF0000" };
|
||||
_intArray = [1, 2, 3, 4, 5];
|
||||
_stringList = ["apple", "banana", "cherry"];
|
||||
_testGuid = Guid.NewGuid();
|
||||
}
|
||||
|
||||
#region Primitive Parameter Benchmarks
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Single int")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public string? RoundTrip_SingleInt()
|
||||
{
|
||||
return _client.PostDataSync<int, string>(BenchmarkSignalRTags.SingleIntParam, 42);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Two ints")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public int RoundTrip_TwoInts()
|
||||
{
|
||||
return _client.PostSync<int>(BenchmarkSignalRTags.TwoIntParams, [10, 20]);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Bool")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public bool RoundTrip_Bool()
|
||||
{
|
||||
return _client.PostDataSync<bool, bool>(BenchmarkSignalRTags.BoolParam, true);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: String")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public string? RoundTrip_String()
|
||||
{
|
||||
return _client.PostDataSync<string, string>(BenchmarkSignalRTags.StringParam, "Hello");
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Guid")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public Guid RoundTrip_Guid()
|
||||
{
|
||||
return _client.PostDataSync<Guid, Guid>(BenchmarkSignalRTags.GuidParam, _testGuid);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: No params")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public string? RoundTrip_NoParams()
|
||||
{
|
||||
return _client.GetAllSync<string>(BenchmarkSignalRTags.NoParams);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Multiple types (3 params)")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public string? RoundTrip_MultipleTypes()
|
||||
{
|
||||
return _client.PostSync<string>(BenchmarkSignalRTags.MultipleTypesParams, [true, "test", 42]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complex Object Benchmarks
|
||||
|
||||
[Benchmark(Description = "RoundTrip: TestOrderItem")]
|
||||
[BenchmarkCategory("Complex")]
|
||||
public TestOrderItem? RoundTrip_TestOrderItem()
|
||||
{
|
||||
return _client.PostDataSync<TestOrderItem, TestOrderItem>(BenchmarkSignalRTags.TestOrderItemParam, _testOrderItem);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: TestOrder (3 items)")]
|
||||
[BenchmarkCategory("Complex")]
|
||||
public TestOrder? RoundTrip_TestOrder()
|
||||
{
|
||||
return _client.PostDataSync<TestOrder, TestOrder>(BenchmarkSignalRTags.TestOrderParam, _testOrder);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: SharedTag")]
|
||||
[BenchmarkCategory("Complex")]
|
||||
public SharedTag? RoundTrip_SharedTag()
|
||||
{
|
||||
return _client.PostDataSync<SharedTag, SharedTag>(BenchmarkSignalRTags.SharedTagParam, _sharedTag);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Collection Benchmarks
|
||||
|
||||
[Benchmark(Description = "RoundTrip: int[] (5 elements)")]
|
||||
[BenchmarkCategory("Collections")]
|
||||
public int[]? RoundTrip_IntArray()
|
||||
{
|
||||
return _client.PostDataSync<int[], int[]>(BenchmarkSignalRTags.IntArrayParam, _intArray);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: List<string> (3 elements)")]
|
||||
[BenchmarkCategory("Collections")]
|
||||
public List<string>? RoundTrip_StringList()
|
||||
{
|
||||
return _client.PostDataSync<List<string>, List<string>>(BenchmarkSignalRTags.StringListParam, _stringList);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mixed Parameter Benchmarks
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Int + DTO")]
|
||||
[BenchmarkCategory("Mixed")]
|
||||
public string? RoundTrip_IntAndDto()
|
||||
{
|
||||
return _client.PostSync<string>(BenchmarkSignalRTags.IntAndDtoParam, [42, _testOrderItem]);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: 5 mixed params")]
|
||||
[BenchmarkCategory("Mixed")]
|
||||
public string? RoundTrip_FiveParams()
|
||||
{
|
||||
return _client.PostSync<string>(BenchmarkSignalRTags.FiveParams, [42, "hello", true, _testGuid, 99.99m]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Benchmark Infrastructure (minimal, reuses production code)
|
||||
|
||||
/// <summary>
|
||||
/// SignalR tags for benchmarks - matches TestSignalRTags structure
|
||||
/// </summary>
|
||||
public abstract class BenchmarkSignalRTags : AcSignalRTags
|
||||
{
|
||||
public const int SingleIntParam = 100;
|
||||
public const int TwoIntParams = 101;
|
||||
public const int BoolParam = 102;
|
||||
public const int StringParam = 103;
|
||||
public const int GuidParam = 104;
|
||||
public const int NoParams = 107;
|
||||
public const int MultipleTypesParams = 109;
|
||||
public const int TestOrderItemParam = 120;
|
||||
public const int TestOrderParam = 121;
|
||||
public const int SharedTagParam = 122;
|
||||
public const int IntArrayParam = 130;
|
||||
public const int StringListParam = 132;
|
||||
public const int IntAndDtoParam = 160;
|
||||
public const int FiveParams = 164;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark-optimized SignalR client with synchronous methods for accurate timing
|
||||
/// </summary>
|
||||
public class BenchmarkSignalRClient : AcSignalRClientBase, IAcSignalRHubItemServer
|
||||
{
|
||||
private readonly BenchmarkSignalRHub _hub;
|
||||
|
||||
public BenchmarkSignalRClient(BenchmarkSignalRHub hub, TestLogger logger) : base(logger)
|
||||
{
|
||||
_hub = hub;
|
||||
// Eliminate polling delay for benchmarks
|
||||
MsDelay = 0;
|
||||
MsFirstDelay = 0;
|
||||
}
|
||||
|
||||
// Synchronous wrappers for benchmarking (avoids async overhead measurement)
|
||||
public TResponse? PostDataSync<TPost, TResponse>(int tag, TPost data)
|
||||
=> PostDataAsync<TPost, TResponse>(tag, data).GetAwaiter().GetResult();
|
||||
|
||||
public TResponse? PostSync<TResponse>(int tag, object[] parameters)
|
||||
=> PostAsync<TResponse>(tag, parameters).GetAwaiter().GetResult();
|
||||
|
||||
public TResponse? GetAllSync<TResponse>(int tag)
|
||||
=> GetAllAsync<TResponse>(tag).GetAwaiter().GetResult();
|
||||
|
||||
protected override Task MessageReceived(int messageTag, byte[] messageBytes) => Task.CompletedTask;
|
||||
protected override HubConnectionState GetConnectionState() => HubConnectionState.Connected;
|
||||
protected override bool IsConnected() => true;
|
||||
protected override Task StartConnectionInternal() => Task.CompletedTask;
|
||||
protected override Task StopConnectionInternal() => Task.CompletedTask;
|
||||
protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask;
|
||||
|
||||
protected override async Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId)
|
||||
{
|
||||
await _hub.OnReceiveMessage(messageTag, messageBytes, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark-optimized SignalR hub
|
||||
/// </summary>
|
||||
public class BenchmarkSignalRHub : AcWebSignalRHubBase<BenchmarkSignalRTags, TestLogger>
|
||||
{
|
||||
private IAcSignalRHubItemServer _callerClient = null!;
|
||||
|
||||
public BenchmarkSignalRHub(TestLogger logger) : base(new ConfigurationBuilder().Build(), logger)
|
||||
{
|
||||
}
|
||||
|
||||
public void RegisterService(object service, IAcSignalRHubItemServer client)
|
||||
{
|
||||
_callerClient = client;
|
||||
DynamicMethodCallModels.Add(new AcDynamicMethodCallModel<SignalRAttribute>(service));
|
||||
}
|
||||
|
||||
protected override string GetConnectionId() => "benchmark-connection";
|
||||
protected override bool IsConnectionAborted() => false;
|
||||
protected override string? GetUserIdentifier() => "benchmark-user";
|
||||
protected override ClaimsPrincipal? GetUser() => null;
|
||||
|
||||
protected override Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId)
|
||||
=> SendMessageToClient(_callerClient, messageTag, message, requestId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark service handlers - same logic as TestSignalRService2
|
||||
/// </summary>
|
||||
public class BenchmarkSignalRService
|
||||
{
|
||||
[SignalR(BenchmarkSignalRTags.SingleIntParam)]
|
||||
public string HandleSingleInt(int value) => $"{value}";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.TwoIntParams)]
|
||||
public int HandleTwoInts(int a, int b) => a + b;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.BoolParam)]
|
||||
public bool HandleBool(bool value) => value;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.StringParam)]
|
||||
public string HandleString(string text) => $"Echo: {text}";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.GuidParam)]
|
||||
public Guid HandleGuid(Guid id) => id;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.NoParams)]
|
||||
public string HandleNoParams() => "OK";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.MultipleTypesParams)]
|
||||
public string HandleMultipleTypes(bool flag, string text, int number) => $"{flag}-{text}-{number}";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.TestOrderItemParam)]
|
||||
public TestOrderItem HandleTestOrderItem(TestOrderItem item) => new()
|
||||
{
|
||||
Id = item.Id,
|
||||
ProductName = $"Processed: {item.ProductName}",
|
||||
Quantity = item.Quantity * 2,
|
||||
UnitPrice = item.UnitPrice * 2,
|
||||
};
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.TestOrderParam)]
|
||||
public TestOrder HandleTestOrder(TestOrder order) => order;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.SharedTagParam)]
|
||||
public SharedTag HandleSharedTag(SharedTag tag) => tag;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.IntArrayParam)]
|
||||
public int[] HandleIntArray(int[] values) => values.Select(x => x * 2).ToArray();
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.StringListParam)]
|
||||
public List<string> HandleStringList(List<string> items) => items.Select(x => x.ToUpper()).ToList();
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.IntAndDtoParam)]
|
||||
public string HandleIntAndDto(int id, TestOrderItem item) => $"{id}-{item?.ProductName}";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.FiveParams)]
|
||||
public string HandleFiveParams(int a, string b, bool c, Guid d, decimal e) => $"{a}-{b}-{c}-{d}-{e}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
using AyCode.Core.Helpers;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using Microsoft.VSDiagnostics;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
[CPUUsageDiagnoser]
|
||||
public class TaskHelperBenchmarks
|
||||
{
|
||||
private volatile bool _flag;
|
||||
private int _counter;
|
||||
private Action _incrementAction = null !;
|
||||
private Func<int> _incrementFunc = null !;
|
||||
private Func<Task<int>> _incrementAsyncFunc = null !;
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
_incrementAction = () => _counter++;
|
||||
_incrementFunc = () => ++_counter;
|
||||
_incrementAsyncFunc = async () =>
|
||||
{
|
||||
await Task.Yield();
|
||||
return ++_counter;
|
||||
};
|
||||
}
|
||||
|
||||
[IterationSetup]
|
||||
public void IterationSetup()
|
||||
{
|
||||
_flag = true; // Pre-set for immediate success
|
||||
_counter = 0;
|
||||
}
|
||||
|
||||
#region WaitToAsync Benchmarks
|
||||
[Benchmark(Description = "WaitToAsync - immediate success")]
|
||||
[BenchmarkCategory("WaitToAsync")]
|
||||
public Task<bool> WaitToAsync_ImmediateSuccess() => TaskHelper.WaitToAsync(() => _flag, 1000, 1);
|
||||
[Benchmark(Description = "WaitToAsync - short timeout (100ms)")]
|
||||
[BenchmarkCategory("WaitToAsync")]
|
||||
public Task<bool> WaitToAsync_ShortTimeout() => TaskHelper.WaitToAsync(() => true, 100, 1);
|
||||
#endregion
|
||||
#region ToThreadPoolTask Benchmarks
|
||||
[Benchmark(Description = "ToThreadPoolTask - Action")]
|
||||
[BenchmarkCategory("ThreadPool")]
|
||||
public Task ToThreadPoolTask_Action() => _incrementAction.ToThreadPoolTask();
|
||||
[Benchmark(Description = "ToThreadPoolTask - Func<T>")]
|
||||
[BenchmarkCategory("ThreadPool")]
|
||||
public Task<int> ToThreadPoolTask_FuncT() => _incrementFunc.ToThreadPoolTask();
|
||||
[Benchmark(Description = "ToThreadPoolTask - Func<Task<T>>")]
|
||||
[BenchmarkCategory("ThreadPool")]
|
||||
public Task<int> ToThreadPoolTask_FuncTaskT() => _incrementAsyncFunc.ToThreadPoolTask();
|
||||
[Benchmark(Description = "Task.Run baseline - Action")]
|
||||
[BenchmarkCategory("ThreadPool")]
|
||||
public Task TaskRun_Action_Baseline() => Task.Run(_incrementAction);
|
||||
#endregion
|
||||
#region Timing Method Comparison
|
||||
[Benchmark(Description = "DateTime.UtcNow.Ticks")]
|
||||
[BenchmarkCategory("Timing")]
|
||||
public long DateTimeUtcNow_Ticks() => DateTime.UtcNow.Ticks;
|
||||
[Benchmark(Description = "Environment.TickCount64")]
|
||||
[BenchmarkCategory("Timing")]
|
||||
public long EnvironmentTickCount64() => Environment.TickCount64;
|
||||
[Benchmark(Description = "DateTime.UtcNow.AddMilliseconds")]
|
||||
[BenchmarkCategory("Timing")]
|
||||
public long DateTimeUtcNow_AddMilliseconds() => DateTime.UtcNow.AddMilliseconds(1000).Ticks;
|
||||
#endregion
|
||||
}
|
||||
Loading…
Reference in New Issue