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:
Loretta 2025-12-11 21:25:50 +01:00
parent 5abff05031
commit c29b3daa0e
44 changed files with 9573 additions and 2597 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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&lt;int&gt; 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

View File

@ -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");
}
}

View File

@ -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
}

View File

@ -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}";
}

View File

@ -1,7 +1,7 @@
 
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18 # Visual Studio Version 18
VisualStudioVersion = 18.0.11222.15 d18.0 VisualStudioVersion = 18.0.11222.15
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AyCode.Core", "AyCode.Core\AyCode.Core.csproj", "{8CCC4969-7306-4747-8A58-80AC5A062EE1}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AyCode.Core", "AyCode.Core\AyCode.Core.csproj", "{8CCC4969-7306-4747-8A58-80AC5A062EE1}"
EndProject EndProject
@ -46,6 +46,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BenchmarkSuite1", "BenchmarkSuite1\BenchmarkSuite1.csproj", "{A20861A9-411E-6150-BF5C-69E8196E5D22}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BenchmarkSuite1", "BenchmarkSuite1\BenchmarkSuite1.csproj", "{A20861A9-411E-6150-BF5C-69E8196E5D22}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AyCode.Services.Tests", "AyCode.Services.Tests\AyCode.Services.Tests.csproj", "{B8443014-1247-FB9C-7BF4-2CC944075A8B}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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.ActiveCfg = Release|Any CPU
{A20861A9-411E-6150-BF5C-69E8196E5D22}.Product|Any CPU.Build.0 = 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.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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

File diff suppressed because it is too large Load Diff

View File

@ -27,11 +27,18 @@ public static class AcJsonSerializer
/// <summary> /// <summary>
/// Serialize object to JSON string with optimized reference handling. /// Serialize object to JSON string with optimized reference handling.
/// Supports primitives, strings, enums, and complex objects.
/// </summary> /// </summary>
public static string Serialize<T>(T value) public static string Serialize<T>(T value)
{ {
if (value == null) return "null"; 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(); var context = new SerializationContext();
// Phase 1: Scan for cross-references (objects that appear multiple times) // Phase 1: Scan for cross-references (objects that appear multiple times)
@ -44,6 +51,185 @@ public static class AcJsonSerializer
return context.GetResult(); 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 #region Phase 1: Reference Scanning
private static void ScanReferences(object? value, SerializationContext context) private static void ScanReferences(object? value, SerializationContext context)
@ -52,17 +238,17 @@ public static class AcJsonSerializer
var type = value.GetType(); var type = value.GetType();
// Skip primitives // Skip primitives - use cached type check
if (IsPrimitiveOrString(type)) return; if (IsPrimitiveOrStringFast(type)) return;
// Track object occurrence // Track object occurrence - inline the check
if (!context.TrackForScanning(value)) if (!context.TrackForScanning(value))
{ {
// Already seen - mark as needing $id // Already seen - mark as needing $id
return; return;
} }
// Scan collections // Scan collections - check IEnumerable before getting metadata
if (value is IEnumerable enumerable && type != typeof(string)) if (value is IEnumerable enumerable && type != typeof(string))
{ {
foreach (var item in enumerable) foreach (var item in enumerable)
@ -73,16 +259,35 @@ public static class AcJsonSerializer
return; return;
} }
// Scan object properties // Scan object properties using cached metadata
var metadata = GetTypeMetadata(type); 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) if (propValue != null)
ScanReferences(propValue, context); 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 #endregion
#region Phase 2: Serialization #region Phase 2: Serialization
@ -101,6 +306,13 @@ public static class AcJsonSerializer
if (TryWritePrimitive(value, type, context)) if (TryWritePrimitive(value, type, context))
return; return;
// Dictionaries - must check before IEnumerable since Dictionary implements IEnumerable
if (value is IDictionary dictionary)
{
WriteDictionary(dictionary, context);
return;
}
// Collections // Collections
if (value is IEnumerable enumerable && type != typeof(string)) if (value is IEnumerable enumerable && type != typeof(string))
{ {
@ -166,6 +378,24 @@ public static class AcJsonSerializer
context.WriteArrayEnd(); 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) private static bool TryWritePrimitive(object value, Type type, SerializationContext context)
{ {
// Handle nullable underlying type // Handle nullable underlying type
@ -292,10 +522,7 @@ public static class AcJsonSerializer
private static bool IsPrimitiveOrString(Type type) private static bool IsPrimitiveOrString(Type type)
{ {
var t = Nullable.GetUnderlyingType(type) ?? type; var t = Nullable.GetUnderlyingType(type) ?? type;
return t.IsPrimitive || t.IsEnum || return IsPrimitiveOrStringFast(t);
t == typeof(string) || t == typeof(decimal) ||
t == typeof(DateTime) || t == typeof(DateTimeOffset) ||
t == typeof(Guid) || t == typeof(TimeSpan);
} }
/// <summary> /// <summary>
@ -463,17 +690,18 @@ public static class AcJsonSerializer
private readonly Dictionary<object, string> _writtenRefs; private readonly Dictionary<object, string> _writtenRefs;
private readonly HashSet<object> _multiReferenced; private readonly HashSet<object> _multiReferenced;
private int _nextId; private int _nextId;
private bool _isWriting;
// Pre-allocated char buffers for number formatting // Use ArrayPool for number buffer to reduce allocations
private readonly char[] _numberBuffer = new char[32]; private static readonly ArrayPool<char> CharPool = ArrayPool<char>.Shared;
private readonly char[] _numberBuffer;
public SerializationContext() public SerializationContext()
{ {
_sb = new StringBuilder(4096); _sb = new StringBuilder(4096);
_scanOccurrences = new Dictionary<object, int>(ReferenceEqualityComparer.Instance); _scanOccurrences = new Dictionary<object, int>(64, ReferenceEqualityComparer.Instance);
_writtenRefs = new Dictionary<object, string>(ReferenceEqualityComparer.Instance); _writtenRefs = new Dictionary<object, string>(32, ReferenceEqualityComparer.Instance);
_multiReferenced = new HashSet<object>(ReferenceEqualityComparer.Instance); _multiReferenced = new HashSet<object>(32, ReferenceEqualityComparer.Instance);
_numberBuffer = CharPool.Rent(64);
_nextId = 1; _nextId = 1;
} }
@ -482,18 +710,18 @@ public static class AcJsonSerializer
/// </summary> /// </summary>
public bool TrackForScanning(object obj) 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); _multiReferenced.Add(obj);
return false; return false;
} }
count = 1;
_scanOccurrences[obj] = 1;
return true; return true;
} }
public void StartWriting() => _isWriting = true; public void StartWriting() { }
/// <summary> /// <summary>
/// Check if this object needs a $id (is referenced elsewhere). /// Check if this object needs a $id (is referenced elsewhere).
@ -526,7 +754,12 @@ public static class AcJsonSerializer
_sb.Append("\"}"); _sb.Append("\"}");
} }
public string GetResult() => _sb.ToString(); public string GetResult()
{
var result = _sb.ToString();
CharPool.Return(_numberBuffer);
return result;
}
// Write methods // Write methods
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]

View File

@ -417,10 +417,10 @@ public static class SerializeObjectExtensions
public static string ToJson<T>(this T source, JsonSerializerSettings? options = null) public static string ToJson<T>(this T source, JsonSerializerSettings? options = null)
{ {
// If custom options are provided, use Newtonsoft for full compatibility // If custom options are provided, use Newtonsoft for full compatibility
if (options != null) //if (options != null)
{ //{
return JsonConvert.SerializeObject(source, options); // return JsonConvert.SerializeObject(source, options);
} //}
// Use our high-performance custom serializer // Use our high-performance custom serializer
return AcJsonSerializer.Serialize(source); 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 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); //=> options != null ? JsonConvert.SerializeObject(source, options) : AcJsonSerializer.Serialize(source);
// OLD: => ((object)source).ToJson(options); => ((object)source).ToJson(options);
public static string ToJson<T>(this IEnumerable<T> source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson 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); //=> options != null ? JsonConvert.SerializeObject(source, options) : AcJsonSerializer.Serialize(source);
// OLD: => ((object)source).ToJson(options); => ((object)source).ToJson(options);
public static T? JsonTo<T>(this string json, JsonSerializerSettings? options = null) public static T? JsonTo<T>(this string json, JsonSerializerSettings? options = null)
{ {
json = JsonUtilities.UnwrapJsonString(json); json = JsonUtilities.UnwrapJsonString(json);
return AcJsonDeserializer.Deserialize<T>(json);
// Use our high-performance custom deserializer for simple deserialization // Use our high-performance custom deserializer
// Only use for: non-abstract classes, with parameterless constructor, not arrays, not primitives // AcJsonDeserializer now supports primitives, enums, and complex types
if (options == null && //if (options == null)
typeof(T).IsClass && //{
!typeof(T).IsAbstract && // try
!typeof(T).IsArray && // {
!typeof(T).IsPrimitive && // return AcJsonDeserializer.Deserialize<T>(json);
typeof(T) != typeof(string) && // }
typeof(T).GetConstructor(Type.EmptyTypes) != null) // catch
{ // {
try // // Fallback to Newtonsoft if custom deserializer fails
{ // }
return (T?)AcJsonDeserializer.Deserialize(json, typeof(T)); //}
}
catch
{
// Fallback to Newtonsoft if custom deserializer fails
}
}
return JsonConvert.DeserializeObject<T>(json, options ?? Options); //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);
} }
public static object? JsonTo(this string json, Type toType, JsonSerializerSettings? options = null) public static object? JsonTo(this string json, Type toType, JsonSerializerSettings? options = null)
{ {
json = JsonUtilities.UnwrapJsonString(json); json = JsonUtilities.UnwrapJsonString(json);
// 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); return AcJsonDeserializer.Deserialize(json, toType);
}
catch
{
// Fallback to Newtonsoft if custom deserializer fails
}
}
return JsonConvert.DeserializeObject(json, toType, options ?? Options); //// 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
// }
//}
// ======================================================================== //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);
} }
public static void JsonTo(this string json, object target, JsonSerializerSettings? options = null) public static void JsonTo(this string json, object target, JsonSerializerSettings? options = null)
{ {
json = JsonUtilities.UnwrapJsonString(json); json = JsonUtilities.UnwrapJsonString(json);
// For populate/merge, we still use Newtonsoft as it handles complex merge logic // Use runtime type instead of compile-time type for Populate
// The AcJsonDeserializer.Populate can be used for simple cases AcJsonDeserializer.Populate(json, target);
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);
} }
[return: NotNullIfNotNull(nameof(src))] [return: NotNullIfNotNull(nameof(src))]
@ -570,9 +532,9 @@ public static class SerializeObjectExtensions
public static void CopyTo(this object? src, object target, JsonSerializerSettings? options = null) public static void CopyTo(this object? src, object target, JsonSerializerSettings? options = null)
=> src?.ToJson(options).JsonTo(target, options); => 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 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); public static T MessagePackTo<T>(this byte[] message, MessagePackSerializerOptions options) => MessagePackSerializer.Deserialize<T>(message, options);
} }

View File

@ -5,20 +5,47 @@
public static bool WaitTo(Func<bool> predicate, int msTimeout = 10000, int msDelay = 5, int msFirstDelay = 0) public static bool WaitTo(Func<bool> predicate, int msTimeout = 10000, int msDelay = 5, int msFirstDelay = 0)
=> WaitToAsync(predicate, msTimeout, msDelay, msFirstDelay).GetAwaiter().GetResult(); => 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) 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; cancellationToken.ThrowIfCancellationRequested();
var dtTimeout = DateTime.UtcNow.AddMilliseconds(msTimeout).Ticks;
if (msFirstDelay > 0) await Task.Delay(msFirstDelay).ConfigureAwait(false); if (predicate())
return true;
while (dtTimeout > DateTime.UtcNow.Ticks && !(result = predicate())) try
await Task.Delay(msDelay).ConfigureAwait(false); //Thread.Sleep(msDelay); {
if (!await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
break;
}
catch (OperationCanceledException)
{
break;
}
}
return result; // Final check
}); return predicate();
} }
public static void Forget(this Task task) public static void Forget(this Task task)
@ -32,43 +59,36 @@
{ {
await task.ConfigureAwait(false); 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) public static void Forget(this ValueTask task)
//{ {
// if (!task.IsCompleted || task.IsFaulted) if (!task.IsCompleted || task.IsFaulted)
// _ = ForgetAwaited(task); _ = ForgetAwaited(task);
// static async ValueTask ForgetAwaited(ValueTask task) static async Task ForgetAwaited(ValueTask task)
// { {
// try try
// { {
// await task.ConfigureAwait(false); await task.ConfigureAwait(false);
// } }
// catch (Exception ex) catch
// { {
// //TODO: .net5, .net6 feature! - J. // Swallow exception - fire and forget semantics
// ValueTask.FromException(ex).ConfigureAwait(true); }
// } }
// } }
//}
//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(this Action action) => ToThreadPoolTask(action).Forget();
public static void RunOnThreadPool<T>(this Func<T> func) => ToThreadPoolTask(func).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 Action action) => Task.Run(action);
public static Task<T> ToThreadPoolTask<T>(this Func<Task<T>> func) => Task.Run(func);
//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<T> func) => Task.Run(func); public static Task<T> ToThreadPoolTask<T>(this Func<T> func) => Task.Run(func);
} }
} }

View File

@ -17,7 +17,7 @@ namespace AyCode.Database.Tests.Internal
{ {
} }
[TestMethod] //[TestMethod]
public override void DatabaseExistsTest() => base.DatabaseExistsTest(); public override void DatabaseExistsTest() => base.DatabaseExistsTest();
} }
} }

View File

@ -3,7 +3,7 @@ using AyCode.Database.Tests.Users;
namespace AyCode.Database.Tests.Internal.Users; namespace AyCode.Database.Tests.Internal.Users;
[TestClass] //[TestClass]
public sealed class UserDalTests : AcUserDalTestBase<UserDal, UserDbContext, User, Profile, UserToken, Company, UserToCompany, Address, EmailMessage> public sealed class UserDalTests : AcUserDalTestBase<UserDal, UserDbContext, User, Profile, UserToken, Company, UserToCompany, Address, EmailMessage>
{ {
[DataTestMethod] [DataTestMethod]

View File

@ -33,6 +33,7 @@
<ProjectReference Include="..\AyCode.Models.Server\AyCode.Models.Server.csproj" /> <ProjectReference Include="..\AyCode.Models.Server\AyCode.Models.Server.csproj" />
<ProjectReference Include="..\AyCode.Models\AyCode.Models.csproj" /> <ProjectReference Include="..\AyCode.Models\AyCode.Models.csproj" />
<ProjectReference Include="..\AyCode.Services.Server\AyCode.Services.Server.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.Services\AyCode.Services.csproj" />
<ProjectReference Include="..\AyCode.Utils.Server\AyCode.Utils.Server.csproj" /> <ProjectReference Include="..\AyCode.Utils.Server\AyCode.Utils.Server.csproj" />
<ProjectReference Include="..\AyCode.Utils\AyCode.Utils.csproj" /> <ProjectReference Include="..\AyCode.Utils\AyCode.Utils.csproj" />

View File

@ -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
}

View File

@ -50,7 +50,8 @@ public class TestObservableDataSource : AcSignalRDataSource<TestDataItem, int, A
#region Mock SignalR Client #region Mock SignalR Client
/// <summary> /// <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> /// </summary>
public class MockSignalRClient : AcSignalRClientBase public class MockSignalRClient : AcSignalRClientBase
{ {
@ -67,7 +68,10 @@ public class MockSignalRClient : AcSignalRClientBase
public static int NextId() => Interlocked.Increment(ref _idCounter); public static int NextId() => Interlocked.Increment(ref _idCounter);
public static void ResetIdCounter() => _idCounter = 0; 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

View File

@ -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&lt;T&gt; methods are properly awaited before serialization.
///
/// Bug scenario: If an async method returns Task&lt;TestOrderItem&gt; 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&lt;string&gt;.
/// 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&lt;TestOrderItem&gt;.
/// 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&lt;int&gt; - 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
}

View File

@ -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}");
}
}

View File

@ -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;
}
}

View File

@ -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&lt;TestOrderItem&gt; 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&lt;int&gt; 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
}

View File

@ -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;
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1,3 @@
// Re-export TestLogger from AyCode.Core.Tests for backward compatibility
namespace AyCode.Services.Server.Tests;

View File

@ -924,6 +924,7 @@ namespace AyCode.Services.Server.SignalRs
return SignalRClient.PostDataAsync(messageTag, item, response => return SignalRClient.PostDataAsync(messageTag, item, response =>
{ {
//response.ResponseDataJson
if (response.Status != SignalResponseStatus.Success || response.ResponseData == null) if (response.Status != SignalResponseStatus.Success || response.ResponseData == null)
{ {
if (TryRollbackItem(item.Id, out _)) return Task.CompletedTask; if (TryRollbackItem(item.Id, out _)) return Task.CompletedTask;

View File

@ -1,5 +1,4 @@
using System.Linq.Expressions; using System.Security.Claims;
using System.Security.Claims;
using AyCode.Core; using AyCode.Core;
using AyCode.Core.Extensions; using AyCode.Core.Extensions;
using AyCode.Core.Helpers; using AyCode.Core.Helpers;
@ -17,135 +16,69 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
: Hub<IAcSignalRHubItemServer>, IAcSignalRHubServer where TSignalRTags : AcSignalRTags where TLogger : AcLoggerBase : Hub<IAcSignalRHubItemServer>, IAcSignalRHubServer where TSignalRTags : AcSignalRTags where TLogger : AcLoggerBase
{ {
protected readonly List<AcDynamicMethodCallModel<SignalRAttribute>> DynamicMethodCallModels = []; protected readonly List<AcDynamicMethodCallModel<SignalRAttribute>> DynamicMethodCallModels = [];
//protected readonly TIAM.Core.Loggers.Logger<AcWebSignalRHubBase<TSignalRTags>> Logger = new(logWriters.ToArray());
protected TLogger Logger = logger; protected TLogger Logger = logger;
protected IConfiguration Configuration = configuration; protected IConfiguration Configuration = configuration;
//private readonly ServiceProviderAPIController _serviceProviderApiController; #region Connection Lifecycle
//private readonly TransferDataAPIController _transferDataApiController;
//_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() public override async Task OnConnectedAsync()
{ {
Logger.Debug($"Server OnConnectedAsync; ConnectionId: {Context.ConnectionId}; UserIdentifier: {Context.UserIdentifier}"); Logger.Debug($"Server OnConnectedAsync; ConnectionId: {GetConnectionId()}; UserIdentifier: {GetUserIdentifier()}");
LogContextUserNameAndId(); LogContextUserNameAndId();
await base.OnConnectedAsync(); await base.OnConnectedAsync();
//Clients.Caller.ConnectionId = Context.ConnectionId;
//Clients.Caller.UserIdentifier = Context.UserIdentifier;
} }
public override async Task OnDisconnectedAsync(Exception? exception) public override async Task OnDisconnectedAsync(Exception? exception)
{ {
var logText = $"Server OnDisconnectedAsync; ConnectionId: {Context.ConnectionId}; UserIdentifier: {Context.UserIdentifier};"; var connectionId = GetConnectionId();
var userIdentifier = GetUserIdentifier();
if (exception == null) Logger.Debug(logText); if (exception == null)
else Logger.Error(logText, exception); Logger.Debug($"Server OnDisconnectedAsync; ConnectionId: {connectionId}; UserIdentifier: {userIdentifier}");
else
Logger.Error($"Server OnDisconnectedAsync; ConnectionId: {connectionId}; UserIdentifier: {userIdentifier}", exception);
LogContextUserNameAndId(); LogContextUserNameAndId();
await base.OnDisconnectedAsync(exception); await base.OnDisconnectedAsync(exception);
} }
#endregion
#region Message Processing
public virtual Task OnReceiveMessage(int messageTag, byte[]? messageBytes, int? requestId) public virtual Task OnReceiveMessage(int messageTag, byte[]? messageBytes, int? requestId)
{ {
return ProcessOnReceiveMessage(messageTag, messageBytes, requestId, null); 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 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}"); if (message is { Length: 0 })
else Logger.Debug($"[{message?.Length:N0}b] {logText}"); {
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 try
{ {
if (AcDomain.IsDeveloperVersion) LogContextUserNameAndId(); 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 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);
if (Logger.LogLevel <= LogLevel.Debug)
{
var responseDataJsonKiloBytes = System.Text.Encoding.Unicode.GetByteCount(responseDataJson.ResponseData ?? "") / 1024;
Logger.Debug($"[{responseDataJsonKiloBytes}kb] responseData serialized to json"); Logger.Debug($"[{responseDataJsonKiloBytes}kb] responseData serialized to json");
}
await ResponseToCaller(messageTag, responseDataJson, requestId); await ResponseToCaller(messageTag, responseDataJson, requestId);
if (methodInfoModel.Attribute.SendToOtherClientType != SendToClientType.None)
SendMessageToOtherClients(methodInfoModel.Attribute.SendToOtherClientTag, responseData).Forget();
return; return;
} }
@ -160,59 +93,184 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
await ResponseToCaller(messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Error), requestId); 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); => 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); => SendMessageToClient(Clients.Caller, messageTag, message, requestId);
protected Task SendMessageToUserId2(string userId, int messageTag, object? content) protected virtual Task SendMessageToUserIdWithContent(string userId, int messageTag, object? content)
=> SendMessageToUserId(userId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null); => 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); => SendMessageToClient(Clients.User(userId), messageTag, message, requestId);
public Task SendMessageToConnectionId2(string connectionId, int messageTag, object? content) protected virtual Task SendMessageToConnectionIdWithContent(string connectionId, int messageTag, object? content)
=> SendMessageToConnectionId(connectionId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null); => SendMessageToConnectionIdInternal(connectionId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null);
public Task SendMessageToConnectionId(string connectionId, int messageTag, ISignalRMessage message, int? requestId) protected virtual Task SendMessageToConnectionIdInternal(string connectionId, int messageTag, ISignalRMessage message, int? requestId)
=> SendMessageToClient(Clients.Client(Context.ConnectionId), messageTag, message, 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); => 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); => SendMessageToClient(Clients.All, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null);
protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null)
protected async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null)
{ {
var responseDataMessagePack = message.ToMessagePack(ContractlessStandardResolver.Options); 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); 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) #endregion
{
//await Clients.Group(groupId).Post("", messageTag, message); #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() protected virtual void LogContextUserNameAndId()
{ {
string? userName = null; var user = GetUser();
var userId = Guid.Empty; if (user == null) return;
if (Context.User != null) var userName = user.Identity?.Name;
{ Guid.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier), out var userId);
userName = Context.User.Identity?.Name;
Guid.TryParse(Context.User.FindFirstValue(ClaimTypes.NameIdentifier), out 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}"); #endregion
else Logger.Debug($"SignalR.Context; userName: {userName}; userId: {userId}");
}
} }

View File

@ -5,14 +5,63 @@ namespace AyCode.Services.Server.SignalRs;
public static class ExtensionMethods public static class ExtensionMethods
{ {
/// <summary>
/// Invokes a method and properly unwraps Task/Task&lt;T&gt; 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) 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)!; // Wait for task completion
return awaitable.GetAwaiter().GetResult(); 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);
}
} }
return methodInfo.Invoke(obj, parameters); // Non-generic Task - no result
return null;
}
// 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;
} }
} }

View File

@ -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>

View File

@ -0,0 +1 @@
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]

File diff suppressed because it is too large Load Diff

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}
}
}

View File

@ -16,16 +16,22 @@ namespace AyCode.Services.SignalRs
{ {
private readonly ConcurrentDictionary<int, SignalRRequestModel> _responseByRequestId = new(); private readonly ConcurrentDictionary<int, SignalRRequestModel> _responseByRequestId = new();
protected readonly HubConnection HubConnection; protected readonly HubConnection? HubConnection;
protected readonly AcLoggerBase Logger; protected readonly AcLoggerBase Logger;
//protected event Action<int, byte[], int?> OnMessageReceived = null!; //protected event Action<int, byte[], int?> OnMessageReceived = null!;
protected abstract Task MessageReceived(int messageTag, byte[] messageBytes); protected abstract Task MessageReceived(int messageTag, byte[] messageBytes);
public int MsDelay = 25;
public int MsFirstDelay = 50;
public int ConnectionTimeout = 10000; public int ConnectionTimeout = 10000;
public int TransportSendTimeout = 60000; public int TransportSendTimeout = 60000;
private const string TagsName = "SignalRTags"; private const string TagsName = "SignalRTags";
/// <summary>
/// Production constructor - creates and starts HubConnection.
/// </summary>
protected AcSignalRClientBase(string fullHubName, AcLoggerBase logger) protected AcSignalRClientBase(string fullHubName, AcLoggerBase logger)
{ {
Logger = logger; Logger = logger;
@ -79,59 +85,162 @@ namespace AyCode.Services.SignalRs
HubConnection.StartAsync().Forget(); 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) private Task HubConnection_Closed(Exception? arg)
{ {
if (_responseByRequestId.IsEmpty) Logger.DebugConditional($"Client HubConnection_Closed"); if (_responseByRequestId.IsEmpty) Logger.DebugConditional($"Client HubConnection_Closed");
else Logger.Warning($"Client HubConnection_Closed; {nameof(_responseByRequestId)} count: {_responseByRequestId.Count}"); else Logger.Warning($"Client HubConnection_Closed; {nameof(_responseByRequestId)} count: {_responseByRequestId.Count}");
_responseByRequestId.Clear(); ClearPendingRequests();
return Task.CompletedTask; 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() public async Task StartConnection()
{ {
if (HubConnection.State == HubConnectionState.Disconnected) if (GetConnectionState() == HubConnectionState.Disconnected)
await HubConnection.StartAsync(); await StartConnectionInternal();
if (HubConnection.State != HubConnectionState.Connected) if (!IsConnected())
await TaskHelper.WaitToAsync(() => HubConnection.State == HubConnectionState.Connected, ConnectionTimeout, 10, 25); await TaskHelper.WaitToAsync(IsConnected, ConnectionTimeout, 10, 25);
} }
public async Task StopConnection() public async Task StopConnection()
{ {
await HubConnection.StopAsync(); await StopConnectionInternal();
await HubConnection.DisposeAsync(); await DisposeConnectionInternal();
} }
public virtual Task SendMessageToServerAsync(int messageTag) 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)}");
await StartConnection();
return StartConnection().ContinueWith(_ =>
{
var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options); var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options);
if (HubConnection.State != HubConnectionState.Connected) if (!IsConnected())
{ {
Logger.Error($"Client SendMessageToServerAsync error! ConnectionSate: {HubConnection.State};"); Logger.Error($"Client SendMessageToServerAsync error! ConnectionState: {GetConnectionState()};");
return Task.CompletedTask; return;
} }
return HubConnection.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, msgp, requestId); await SendToHubAsync(messageTag, msgp, requestId);
});
} }
#region CRUD #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 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) public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object id)
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(id)), responseCallback); => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(id)), responseCallback);
public virtual Task<TResponseData?> GetByIdAsync<TResponseData>(int messageTag, object[] ids) //where TResponseData : class 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) public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object[] ids)
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(ids)), responseCallback); => 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) 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); => 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 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 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 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 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 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 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 #endregion CRUD
public virtual Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag) //where TResponse : class 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 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 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)}"); Logger.DebugConditional($"Client SendMessageToServerAsync<TResult>; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
var startTime = DateTime.Now; var startTime = DateTime.Now;
var requestModel = SignalRRequestModelPool.Get();
_responseByRequestId[requestId] = new SignalRRequestModel(); _responseByRequestId[requestId] = requestModel;
await SendMessageToServerAsync(messageTag, message, requestId); await SendMessageToServerAsync(messageTag, message, requestId);
try 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) _responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is ISignalResponseMessage<string> responseMessage)
{ {
SignalRRequestModelPool.Return(obj);
if (responseMessage.Status == SignalResponseStatus.Error || responseMessage.ResponseData == null) 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); Logger.Error(errorText);
@ -213,14 +357,17 @@ namespace AyCode.Services.SignalRs
return responseMessage.ResponseData.JsonTo<TResponse>(); 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) 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; return default;
} }
@ -231,9 +378,8 @@ namespace AyCode.Services.SignalRs
{ {
if (messageTag == 0) Logger.Error($"SendMessageToServerAsync; messageTag == 0"); if (messageTag == 0) Logger.Error($"SendMessageToServerAsync; messageTag == 0");
var requestId = AcDomain.NextUniqueInt32; var requestId = GetNextRequestId();
var requestModel = SignalRRequestModelPool.Get(new Action<ISignalResponseMessage<string>>(responseMessage =>
_responseByRequestId[requestId] = new SignalRRequestModel(new Action<ISignalResponseMessage<string>>(responseMessage =>
{ {
TResponseData? responseData = default; TResponseData? responseData = default;
@ -241,14 +387,21 @@ namespace AyCode.Services.SignalRs
{ {
responseData = string.IsNullOrEmpty(responseMessage.ResponseData) ? default : responseMessage.ResponseData.JsonTo<TResponseData?>(); 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)); responseCallback(new SignalResponseMessage<TResponseData?>(messageTag, responseMessage.Status, responseData));
})); }));
_responseByRequestId[requestId] = requestModel;
return SendMessageToServerAsync(messageTag, message, requestId); 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) public virtual Task OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)
{ {
var logText = $"Client OnReceiveMessage; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}"; var logText = $"Client OnReceiveMessage; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}";
@ -264,7 +417,7 @@ namespace AyCode.Services.SignalRs
_responseByRequestId[reqId].ResponseDateTime = DateTime.UtcNow; _responseByRequestId[reqId].ResponseDateTime = DateTime.UtcNow;
Logger.Debug($"[{_responseByRequestId[reqId].ResponseDateTime.Subtract(_responseByRequestId[reqId].RequestDateTime).TotalMilliseconds:N0}ms][{(messageBytes.Length / 1024)}kb]{logText}"); 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) switch (_responseByRequestId[reqId].ResponseByRequestId)
{ {
@ -273,55 +426,47 @@ namespace AyCode.Services.SignalRs
return Task.CompletedTask; return Task.CompletedTask;
case Action<ISignalResponseMessage<string>> messagePackCallback: case Action<ISignalResponseMessage<string>> messagePackCallback:
_responseByRequestId.TryRemove(reqId, out _); if (_responseByRequestId.TryRemove(reqId, out var callbackModel))
{
SignalRRequestModelPool.Return(callbackModel);
}
messagePackCallback.Invoke(responseMessage); messagePackCallback.Invoke(responseMessage);
return Task.CompletedTask; return Task.CompletedTask; // ← Callback: NEM hívjuk meg a MessageReceived-et
//case Action<string> jsonCallback:
// _responseByRequestId.TryRemove(reqId, out _);
// jsonCallback.Invoke(responseMessage);
// return Task.CompletedTask;
default: default:
Logger.Error($"Client OnReceiveMessage switch; unknown message type: {_responseByRequestId[reqId].ResponseByRequestId?.GetType().Name}; {ConstHelper.NameByValue(TagsName, messageTag)}"); Logger.Error($"Client OnReceiveMessage switch; unknown message type: {_responseByRequestId[reqId].ResponseByRequestId?.GetType().Name}; {ConstHelper.NameByValue(TagsName, messageTag)}");
break; break;
} }
_responseByRequestId.TryRemove(reqId, out _); if (_responseByRequestId.TryRemove(reqId, out var removedModel))
{
SignalRRequestModelPool.Return(removedModel);
} }
else Logger.Info(logText);
// Request-response hibás eset - ne hívjuk meg a MessageReceived-et
return Task.CompletedTask;
}
// Csak broadcast/notification üzeneteknél hívjuk meg a MessageReceived-et
Logger.Info(logText);
MessageReceived(messageTag, messageBytes).Forget(); MessageReceived(messageTag, messageBytes).Forget();
} }
catch (Exception ex) catch (Exception ex)
{ {
if (requestId.HasValue) if (requestId.HasValue && _responseByRequestId.TryRemove(requestId.Value, out var exModel))
_responseByRequestId.TryRemove(requestId.Value, out _); {
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; throw;
} }
return Task.CompletedTask; return Task.CompletedTask;
} }
//public virtual Task OnRequestMessage(int messageTag, int requestId)
//{
// Logger.DebugConditional($"Client OnRequestMessage; {nameof(messageTag)}: {messageTag}; {nameof(requestId)}: {requestId};");
// try protected virtual SignalResponseJsonMessage DeserializeResponseMsgPack(byte[] messageBytes)
// { => messageBytes.MessagePackTo<SignalResponseJsonMessage>(ContractlessStandardResolver.Options);
// OnMessageRequested(messageTag, requestId);
// }
// catch(Exception ex)
// {
// Logger.Error($"Client OnReceiveMessage; {nameof(messageTag)}: {messageTag}; {nameof(requestId)}: {requestId}; {ex.Message}", ex);
// throw;
// }
// return Task.CompletedTask;
//}
} }
} }

View File

@ -3,4 +3,7 @@
public class AcSignalRTags public class AcSignalRTags
{ {
public const int None = 0; public const int None = 0;
public const int PingTag = 90001;
public const int EchoTag = 90002;
} }

View File

@ -10,47 +10,51 @@ namespace AyCode.Services.SignalRs;
public class IdMessage public class IdMessage
{ {
public List<string> Ids { get; private set; } = []; public List<string> Ids { get; private set; }
public IdMessage() 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); // Pre-allocate capacity to avoid list resizing
Ids.AddRange(ids.Select(x => Ids = new List<string>(ids.Length);
for (var i = 0; i < ids.Length; i++)
{ {
string item; Ids.Add(ids[i].ToJson());
}
//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;
}));
} }
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() public override string ToString()
@ -59,17 +63,18 @@ public class IdMessage
} }
} }
[MessagePackObject]
public class SignalPostJsonMessage public class SignalPostJsonMessage
{ {
[Key(0)] [Key(0)]
public string PostDataJson { get; set; } public string PostDataJson { get; set; } = "";
public SignalPostJsonMessage() public SignalPostJsonMessage()
{} {}
protected SignalPostJsonMessage(string postDataJson) => PostDataJson = postDataJson; protected SignalPostJsonMessage(string postDataJson) => PostDataJson = postDataJson;
} }
[MessagePackObject] [MessagePackObject(AllowPrivate = false)]
public class SignalPostJsonDataMessage<TPostDataType> : SignalPostJsonMessage, ISignalPostMessage<TPostDataType> //where TPostDataType : class public class SignalPostJsonDataMessage<TPostDataType> : SignalPostJsonMessage, ISignalPostMessage<TPostDataType> //where TPostDataType : class
{ {
[IgnoreMember] [IgnoreMember]
@ -144,16 +149,33 @@ public sealed class SignalResponseJsonMessage : ISignalResponseMessage<string>
MessageTag = messageTag; 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) public SignalResponseJsonMessage(int messageTag, SignalResponseStatus status, object? responseData) : this(messageTag, status)
{ {
if (responseData is string stringdata) if (responseData == null)
ResponseData = stringdata; {
else ResponseData = responseData.ToJson(); ResponseData = null;
return;
} }
public SignalResponseJsonMessage(int messageTag, SignalResponseStatus status, string? responseDataJson) : this(messageTag, status) // If responseData is already a JSON string, use it directly
if (responseData is string strData)
{ {
ResponseData = responseDataJson; 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. /// ResponseData is only deserialized on first access and cached.
/// Use ResponseDataJson for direct JSON access without deserialization. /// Use ResponseDataJson for direct JSON access without deserialization.
/// </summary> /// </summary>
[MessagePackObject] [MessagePackObject(AllowPrivate = false)]
public sealed class SignalResponseMessage<TResponseData> : ISignalResponseMessage<TResponseData> public sealed class SignalResponseMessage<TResponseData> : ISignalResponseMessage<TResponseData>
{ {
[IgnoreMember] [IgnoreMember]

View File

@ -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 RequestDateTime;
public DateTime ResponseDateTime; public DateTime ResponseDateTime;
public object? ResponseByRequestId = null; public object? ResponseByRequestId;
public SignalRRequestModel() public SignalRRequestModel()
{ {
@ -16,4 +22,54 @@ public class SignalRRequestModel
ResponseByRequestId = responseByRequestId; 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);
} }

View File

@ -14,6 +14,10 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" /> <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>
<ItemGroup> <ItemGroup>

View File

@ -1,4 +1,5 @@
using BenchmarkDotNet.Running; using BenchmarkDotNet.Running;
using AyCode.Core.Benchmarks;
namespace BenchmarkSuite1 namespace BenchmarkSuite1
{ {
@ -13,7 +14,8 @@ namespace BenchmarkSuite1
return; return;
} }
var _ = BenchmarkRunner.Run(typeof(Program).Assembly); // Use assembly-wide discovery for all benchmarks
BenchmarkSwitcher.FromAssembly(typeof(SerializationBenchmarks).Assembly).Run(args);
} }
static void RunSizeComparison() static void RunSizeComparison()

View File

@ -1,21 +1,28 @@
using AyCode.Core.Extensions; using AyCode.Core.Extensions;
using AyCode.Core.Interfaces; using AyCode.Core.Tests.TestModels;
using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Attributes;
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Text; using System.Text;
namespace AyCode.Core.Benchmarks; namespace AyCode.Core.Benchmarks;
/// <summary>
/// Serialization benchmarks comparing AcJsonSerializer/Deserializer with Newtonsoft.Json.
/// Uses shared TestModels from AyCode.Core.Tests for consistency.
/// </summary>
[MemoryDiagnoser] [MemoryDiagnoser]
public class SerializationBenchmarks public class SerializationBenchmarks
{ {
// Complex graph with 7 levels, ~1500 objects, cross-references // Test data - uses shared TestModels
private Level1_Company _complexGraph = null!; private TestOrder _testOrder = null!;
// Pre-serialized JSON for deserialization benchmarks // Pre-serialized JSON for deserialization benchmarks
private string _newtonsoftJson = null!; private string _newtonsoftJson = null!;
private string _ayCodeJson = null!; private string _ayCodeJson = null!;
// Target objects for Populate benchmarks
private TestOrder _populateTarget = null!;
// Settings // Settings
private JsonSerializerSettings _newtonsoftNoRefSettings = null!; private JsonSerializerSettings _newtonsoftNoRefSettings = null!;
private JsonSerializerSettings _ayCodeSettings = null!; private JsonSerializerSettings _ayCodeSettings = null!;
@ -28,29 +35,36 @@ public class SerializationBenchmarks
{ {
ReferenceLoopHandling = ReferenceLoopHandling.Ignore, ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
NullValueHandling = NullValueHandling.Ignore, NullValueHandling = NullValueHandling.Ignore,
DefaultValueHandling = DefaultValueHandling.Ignore, // Fair comparison - also skip defaults DefaultValueHandling = DefaultValueHandling.Ignore,
Formatting = Formatting.None Formatting = Formatting.None
}; };
// AyCode WITH reference handling (our optimized solution) // AyCode WITH reference handling
_ayCodeSettings = SerializeObjectExtensions.Options; _ayCodeSettings = SerializeObjectExtensions.Options;
// Create complex 7-level graph with ~1500 objects and cross-references // Create benchmark data using shared factory
_complexGraph = CreateComplexGraph(); // ~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 // Pre-serialize for deserialization benchmarks
_newtonsoftJson = JsonConvert.SerializeObject(_complexGraph, _newtonsoftNoRefSettings); _newtonsoftJson = JsonConvert.SerializeObject(_testOrder, _newtonsoftNoRefSettings);
_ayCodeJson = _complexGraph.ToJson(_ayCodeSettings); _ayCodeJson = _testOrder.ToJson(_ayCodeSettings);
// Create target for populate benchmarks
_populateTarget = new TestOrder();
// Output sizes for comparison // Output sizes for comparison
var newtonsoftBytes = Encoding.UTF8.GetByteCount(_newtonsoftJson); var newtonsoftBytes = Encoding.UTF8.GetByteCount(_newtonsoftJson);
var ayCodeBytes = Encoding.UTF8.GetByteCount(_ayCodeJson); var ayCodeBytes = Encoding.UTF8.GetByteCount(_ayCodeJson);
Console.WriteLine("=== JSON Size Comparison ==="); Console.WriteLine("=== JSON Size Comparison ===");
Console.WriteLine($"Newtonsoft (skip defaults): {_newtonsoftJson.Length:N0} chars ({newtonsoftBytes:N0} bytes, {newtonsoftBytes / 1024.0:F1} KB)"); Console.WriteLine($"Newtonsoft (skip defaults): {_newtonsoftJson.Length:N0} chars ({newtonsoftBytes:N0} bytes)");
Console.WriteLine($"AcJsonSerializer (refs+skip): {_ayCodeJson.Length:N0} chars ({ayCodeBytes:N0} bytes, {ayCodeBytes / 1024.0:F1} KB)"); 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($"Size reduction: {(1.0 - (double)ayCodeBytes / newtonsoftBytes) * 100:F1}%");
Console.WriteLine($"Bytes saved: {newtonsoftBytes - ayCodeBytes:N0}");
} }
#region Serialization Benchmarks #region Serialization Benchmarks
@ -58,12 +72,17 @@ public class SerializationBenchmarks
[Benchmark(Description = "Newtonsoft (no refs)")] [Benchmark(Description = "Newtonsoft (no refs)")]
[BenchmarkCategory("Serialize")] [BenchmarkCategory("Serialize")]
public string Serialize_Newtonsoft_NoRefs() public string Serialize_Newtonsoft_NoRefs()
=> JsonConvert.SerializeObject(_complexGraph, _newtonsoftNoRefSettings); => JsonConvert.SerializeObject(_testOrder, _newtonsoftNoRefSettings);
[Benchmark(Description = "AyCode (with refs)")] [Benchmark(Description = "AyCode (with refs)")]
[BenchmarkCategory("Serialize")] [BenchmarkCategory("Serialize")]
public string Serialize_AyCode_WithRefs() 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 #endregion
@ -71,22 +90,44 @@ public class SerializationBenchmarks
[Benchmark(Description = "Newtonsoft (no refs)")] [Benchmark(Description = "Newtonsoft (no refs)")]
[BenchmarkCategory("Deserialize")] [BenchmarkCategory("Deserialize")]
public Level1_Company? Deserialize_Newtonsoft_NoRefs() public TestOrder? Deserialize_Newtonsoft_NoRefs()
=> JsonConvert.DeserializeObject<Level1_Company>(_newtonsoftJson, _newtonsoftNoRefSettings); => JsonConvert.DeserializeObject<TestOrder>(_newtonsoftJson, _newtonsoftNoRefSettings);
[Benchmark(Description = "AyCode (with refs)")] [Benchmark(Description = "AyCode (with refs)")]
[BenchmarkCategory("Deserialize")] [BenchmarkCategory("Deserialize")]
public Level1_Company? Deserialize_AyCode_WithRefs() public TestOrder? Deserialize_AyCode_WithRefs()
=> _ayCodeJson.JsonTo<Level1_Company>(_ayCodeSettings); => _ayCodeJson.JsonTo<TestOrder>(_ayCodeSettings);
[Benchmark(Description = "AcJsonDeserializer (custom)")] [Benchmark(Description = "AcJsonDeserializer (custom)")]
[BenchmarkCategory("Deserialize")] [BenchmarkCategory("Deserialize")]
public Level1_Company? Deserialize_AcJsonDeserializer() public TestOrder? Deserialize_AcJsonDeserializer()
=> AcJsonDeserializer.Deserialize<Level1_Company>(_ayCodeJson); => AcJsonDeserializer.Deserialize<TestOrder>(_ayCodeJson);
#endregion #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")] [Benchmark(Description = "JSON Size - Newtonsoft")]
[BenchmarkCategory("Size")] [BenchmarkCategory("Size")]
@ -97,420 +138,4 @@ public class SerializationBenchmarks
public int JsonSize_AyCode() => _ayCodeJson.Length; public int JsonSize_AyCode() => _ayCodeJson.Length;
#endregion #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
} }

View File

@ -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
}

View File

@ -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

View File

@ -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
}