Compare commits

...

18 Commits

Author SHA1 Message Date
Loretta 60238952d8 Rename BenchmarkSuite1 to AyCode.Benchmark project
Renamed the benchmark project from BenchmarkSuite1 to AyCode.Benchmark, updating the solution reference and moving all benchmark source files under the new project and namespace. No functional changes were made; this is a structural and naming reorganization for clarity and maintainability.
2025-12-13 10:11:39 +01:00
Loretta 9f1c31bd15 Centralize test/benchmark results; optimize deserializer
Introduce a unified Test_Benchmark_Results directory for all test, benchmark, and coverage artifacts, with MSBuild properties and MSTest runsettings for consistent output. Update .gitignore to exclude results. Refactor BenchmarkSuite1 to ensure all logs and artifacts are stored in versioned subfolders, and add logic for coverage file management.

Majorly optimize AcBinaryDeserializer: reuse existing nested objects and collections during deserialization, add PopulateListOptimized for in-place list updates, and use String.Create for efficient UTF8 decoding. Extend property metadata to track complex/collection types. Update MessagePack and BenchmarkDotNetDiagnosers package versions. Remove obsolete benchmark-report.html.
2025-12-13 09:59:18 +01:00
Loretta 056aae97a5 Optimize AcBinarySerializer: typed accessors & array writers
- Add typed property accessors to avoid boxing and speed up value type serialization
- Implement bulk array writers for primitives (int, long, double, etc.) for efficient, zero-copy serialization
- Add zero-copy IBufferWriter serialization and size estimation methods
- Refactor array/dictionary serialization for fast paths and memory efficiency
- Improve context pool memory management and reduce initial dictionary/set capacities
- Fix benchmark to avoid state accumulation between runs
- Downgrade MessagePack dependency for compatibility
2025-12-13 03:25:02 +01:00
Loretta f69b14c195 AcBinary: Major perf/memory optimizations & new benchmarks
- Zero-allocation hot paths for primitive (de)serialization using MemoryMarshal/Unsafe
- FrozenDictionary-based type dispatch for fast deserialization
- Optimized span-based UTF8 string handling, stackalloc for small strings
- Specialized fast-paths for primitive arrays (int, double, etc.)
- Binary header now uses flag-based format (48+) for metadata/ref handling
- Improved buffer management with ArrayPool and minimum size
- Property access via for-loops for better JIT and less overhead
- SignalR test infra supports full serializer options (WithRef/NoRef)
- Added comprehensive AcBinary vs MessagePack benchmarks (speed, memory, size)
- Added rich HTML benchmark report (benchmark-report.html)
- Updated JsonUtilities for new header detection
- Improved documentation and code comments throughout
2025-12-13 01:24:48 +01:00
Loretta 6faed09f9f Fix binary deserializer string interning and add regressions
- Fix: Ensure property names and skipped strings are interned during binary deserialization, preventing interned string index mismatches.
- Add: Extensive regression tests for string interning edge cases, including property mismatch (server has more properties than client), deeply nested objects, repeated/unique strings, and empty/null handling.
- Add: New test DTOs and infrastructure for property mismatch scenarios.
- Update: SignalR test helpers and services to support both JSON and Binary serialization in all tests.
- Improve: SignalRRequestModelPool now initializes models on Get().
- These changes address production bugs and ensure robust, consistent string interning and property skipping in AcBinarySerializer.
2025-12-13 00:12:21 +01:00
Loretta 1a9e760b68 Fix Binary serialization of string DateTimes in attributes
Add regression tests for string-stored DateTime values in GenericAttribute-like collections, ensuring exact value preservation in Binary serialization. Fix AcBinaryDeserializer to only intern strings >= 4 chars, matching serializer logic and preventing intern table index mismatches. Refactor SignalR tests to run under both JSON and Binary modes, and add comprehensive tests for edge cases and production bug scenarios. Clean up test code and comments for clarity.
2025-12-12 22:59:54 +01:00
Loretta 09a4604e52 Add binary serialization support for SignalR responses
Introduces SignalResponseBinaryMessage for efficient binary serialization of response data alongside existing JSON support. Adds utilities to detect serializer type and updates both server and client logic to handle JSON or binary formats automatically. Refactors response creation, logging, and deserialization for consistency and performance. Updates benchmarks and ensures all MessagePack operations use ContractlessStandardResolver.Options. Improves robustness and backward compatibility in client callback handling.
2025-12-12 21:40:48 +01:00
Loretta 2147d981db Refactor: new high-performance binary serializer/deserializer
Major overhaul of binary serialization:
- Rewrote AcBinarySerializer as a static, optimized, feature-rich serializer with VarInt encoding, string interning, property name tables, reference handling, and optional metadata.
- Added AcBinaryDeserializer with matching features, including merge/populate support and robust error handling.
- Introduced AcBinarySerializerOptions and AcSerializerOptions base class for unified serializer configuration (JSON/binary).
- Added generic extension methods for "any" serialization/deserialization based on options.
- Updated tests and benchmarks for new APIs; fixed null byte code and added DateTimeKind test.
- Fixed namespace typos and improved code style and documentation.
2025-12-12 21:03:39 +01:00
Loretta b9e83e2ef8 Add AcBinarySerializer tests, helpers, and benchmark updates
- Introduce AcBinarySerializerTests with full coverage for primitives, objects, collections, merge/populate, and size comparisons
- Add AcBinarySerializer class stub as a placeholder for implementation
- Extend serialization extension methods with binary helpers (ToBinary, BinaryTo, BinaryCloneTo, etc.)
- Update test models to ignore parent references for all major serializers ([IgnoreMember], [BsonIgnore])
- Refactor benchmarks: split into minimal, simple, complex, and MessagePack comparison; add command-line switches and improved size reporting
- Optimize AcJsonDeserializer with fast UTF-8 property lookup and direct primitive setting
- Add MessagePack and MongoDB.Bson dependencies to test and benchmark projects
- Add (accidentally) a summary of less commands as a documentation artifact
2025-12-12 20:06:00 +01:00
Loretta a945db9b09 High-perf streaming JSON (de)serialization, refactor
Major upgrade to AcJsonSerializer/AcJsonDeserializer:
- Add Utf8JsonReader/Writer fast-paths for streaming, allocation-free (de)serialization when reference handling is not needed, matching STJ performance.
- Populate/merge now uses streaming for in-place updates.
- Type metadata caches TypeCode, UnderlyingType, and uses frozen dictionaries for hot property lookup.
- Context pooling reduces allocations and GC pressure.
- Primitive (de)serialization uses TypeCode-based fast paths; improved enum, Guid, DateTime, etc. handling.
- Shared UTF8 buffer pool for efficient encoding/decoding.
- Pre-encoded property names and STJ writer for output.
- Improved validation, error handling, and double-serialization detection.
- Expanded benchmarks: small/medium/large, with/without refs, AyCode vs STJ vs Newtonsoft, grouped by scenario.
- General code modernization: aggressive inlining, ref params, ReferenceEquals, improved naming, and comments.
- Unified IId<T> and collection element detection; consistent $id/$ref handling.

Brings AyCode JSON (de)serializer to near-parity with STJ for non-ref scenarios, while retaining advanced features and improving maintainability and performance.
2025-12-12 16:23:54 +01:00
Loretta ad426feba4 Refactor JSON (de)serialization: options, depth, utilities
Major overhaul of JSON serialization/deserialization:
- Introduce AcJsonSerializerOptions for reference handling and max depth
- Centralize type checks, primitive/collection logic in JsonUtilities
- Add depth limiting to serializer/deserializer (MaxDepth support)
- Make $id/$ref reference handling optional via options
- Unify and simplify public API (ToJson, JsonTo, CloneTo, CopyTo, etc.)
- Improve primitive, enum, and collection handling and caching
- Refactor contract resolver and merge logic to use new utilities
- Remove redundant code, centralize string escaping/unescaping
- Update all tests and benchmarks to use new API and options
- Fix minor bugs and improve error handling and validation

This modernizes and unifies the JSON infrastructure for better performance, flexibility, and maintainability.
2025-12-12 11:30:55 +01:00
Loretta 8e7869b3da Improve JSON deserialization and observable collection handling
- Add batch update support for IAcObservableCollection during deserialization to suppress per-item notifications and fire a single reset event.
- Throw AcJsonDeserializationException with a clear message if a type lacks a parameterless constructor during deserialization.
- Enhance IsGenericCollectionType to recognize more collection types, including ObservableCollection<T> and custom IList<T> implementations.
- Improve array and collection deserialization logic to better handle target types and fall back to List<T> if needed.
- In AcObservableCollection, catch and ignore ObjectDisposedException in event handlers to prevent errors from disposed subscribers.
- Remove redundant batch update logic from AcSignalRDataSource; rely on deserializer's handling.
- Set SkipNegotiation = true in SignalR client options for WebSockets-only optimization and comment out automatic HubConnection.StartAsync() for more controlled connection management.
2025-12-11 23:46:30 +01:00
Loretta c29b3daa0e 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.
2025-12-11 21:25:50 +01:00
Loretta 5abff05031 lot of improvements and new mstests... 2025-12-11 21:24:32 +01:00
Loretta a0445e6d1e Improve JSON (de)serializer: WASM, SignalR, perf, tests
- Use compiled expression tree accessors for property get/set (AOT/WASM compatible, much faster than reflection)
- Add comprehensive WASM/AOT compatibility and SignalR parameter array tests
- Correctly handle $id/$ref for shared references; optimize reference resolution
- Always serialize empty collections as [], omit null collections
- Optimize primitive reading and type metadata caching
- Fix edge cases in array, primitive, and reference deserialization
- Ensure output matches Newtonsoft.Json for arrays and primitives
- Greatly expand test coverage for all major scenarios
2025-12-09 11:26:55 +01:00
Loretta f9dc9a65fb High-performance, thread-safe JSON and data source overhaul
- Introduced AcJsonSerializer/Deserializer: fast, reflection-free, streaming JSON with optimized $id/$ref handling and Base62 IDs.
- Default serialization now uses new serializers; falls back to Newtonsoft for complex cases.
- Extensive type/property caching for performance and thread safety.
- Refactored MergeContractResolver and collection merge logic; all merge/populate operations use centralized caches.
- AcObservableCollection and AcSignalRDataSource are now fully thread-safe and support batch operations.
- SignalResponseMessage<T> supports lazy deserialization and direct JSON access.
- Added comprehensive unit tests and benchmarks for serialization, deserialization, and collection operations.
- Updated .gitignore and solution files; refactored core classes for clarity and performance.
2025-12-09 03:24:51 +01:00
Loretta 166d97106d Enhance JSON handling and add hybrid reference support
- Updated all projects to use `Newtonsoft.Json` v13.0.3 for consistency.
- Introduced `HybridReferenceResolver` for semantic and numeric ID handling.
- Refactored `SerializeObjectExtensions` to support deep JSON merging.
- Simplified `IId<T>` interface by removing `IEquatable<T>` constraint.
- Improved `AcSignalRDataSource` with robust `AddRange` and `CopyTo` methods.
- Added `JsonExtensionTests` for deep hierarchy, reference, and edge cases.
- Implemented `UnifiedMergeContractResolver` for custom JSON behavior.
- Optimized type/property caching with `TypeCache` and `CachedPropertyInfo`.
- Enhanced SignalR integration to fix primitive array deserialization issues.
- Introduced `JsonNoMergeCollection` attribute for replace-only collections.
- Added test DTOs and `TestDataFactory` for real-world scenario simulations.
- Improved performance with `ConcurrentDictionary` and `ObjectPool`.
- Fixed `$id`/`$ref` handling for non-semantic references and arrays.
2025-12-08 15:50:48 +01:00
Loretta f3ec941774 microsoft packages update 2025-12-01 16:18:36 +01:00
67 changed files with 21475 additions and 1081 deletions

5
.gitignore vendored
View File

@ -372,4 +372,7 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
FodyWeavers.xsd
/BenchmarkSuite1/Results
/CoverageReport
/Test_Benchmark_Results

View File

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.2" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers" Version="18.3.36812.1" />
<PackageReference Include="MongoDB.Bson" Version="3.5.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
<ProjectReference Include="..\AyCode.Core.Tests\AyCode.Core.Tests.csproj" />
<ProjectReference Include="..\AyCode.Services\AyCode.Services.csproj" />
<ProjectReference Include="..\AyCode.Services.Server\AyCode.Services.Server.csproj" />
<ProjectReference Include="..\AyCode.Models.Server\AyCode.Models.Server.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Results\" />
</ItemGroup>
</Project>

272
AyCode.Benchmark/Program.cs Normal file
View File

@ -0,0 +1,272 @@
using BenchmarkDotNet.Running;
using AyCode.Core.Benchmarks;
using AyCode.Core.Extensions;
using AyCode.Core.Tests.TestModels;
using System.Text;
using MessagePack;
using MessagePack.Resolvers;
using BenchmarkDotNet.Configs;
using System.IO;
namespace AyCode.Benchmark
{
internal class Program
{
static void Main(string[] args)
{
// Ensure centralized results directory and subfolders exist
var baseResultsDir = Path.Combine(Directory.GetCurrentDirectory(), "Test_Benchmark_Results");
var mstestDir = Path.Combine(baseResultsDir, "MSTest");
var benchmarkDir = Path.Combine(baseResultsDir, "Benchmark");
var coverageDir = Path.Combine(baseResultsDir, "CoverageReport");
var memDiagDir = Path.Combine(baseResultsDir, "MemDiag");
Directory.CreateDirectory(mstestDir);
Directory.CreateDirectory(benchmarkDir);
Directory.CreateDirectory(coverageDir);
Directory.CreateDirectory(memDiagDir);
// Create .gitignore in results folder to keep it out of source control except the file itself
var gitignorePath = Path.Combine(baseResultsDir, ".gitignore");
if (!File.Exists(gitignorePath))
{
File.WriteAllText(gitignorePath, "*\n!.gitignore\n");
}
// If requested, save/move a coverage file into the CoverageReport folder
if (args.Length > 0 && args[0] == "--save-coverage")
{
if (args.Length < 2)
{
Console.Error.WriteLine("Usage: --save-coverage <coverage-file-path>");
return;
}
var src = args[1];
if (!File.Exists(src))
{
Console.Error.WriteLine("Coverage file not found: " + src);
return;
}
try
{
var dest = Path.Combine(coverageDir, Path.GetFileName(src));
File.Copy(src, dest, overwrite: true);
Console.WriteLine("Coverage file saved to: " + dest);
}
catch (Exception ex)
{
Console.Error.WriteLine("Failed to save coverage file: " + ex.Message);
}
return;
}
// Configure BenchmarkDotNet to write artifacts into the centralized benchmark directory
var config = ManualConfig.Create(DefaultConfig.Instance)
.WithArtifactsPath(benchmarkDir);
if (args.Length > 0 && args[0] == "--test")
{
var (inDir, outDir) = CreateMSTestDeployDirs(mstestDir);
RunQuickTest(outDir);
return;
}
if (args.Length > 0 && args[0] == "--testmsgpack")
{
var (inDir, outDir) = CreateMSTestDeployDirs(mstestDir);
RunMessagePackTest(outDir);
return;
}
if (args.Length > 0 && args[0] == "--minimal")
{
RunBenchmark<MinimalBenchmark>(config, benchmarkDir, memDiagDir, "MinimalBenchmark");
return;
}
if (args.Length > 0 && args[0] == "--simple")
{
RunBenchmark<SimpleBinaryBenchmark>(config, benchmarkDir, memDiagDir, "SimpleBinaryBenchmark");
return;
}
if (args.Length > 0 && args[0] == "--complex")
{
RunBenchmark<ComplexBinaryBenchmark>(config, benchmarkDir, memDiagDir, "ComplexBinaryBenchmark");
return;
}
if (args.Length > 0 && args[0] == "--msgpack")
{
RunBenchmark<MessagePackComparisonBenchmark>(config, benchmarkDir, memDiagDir, "MessagePackComparisonBenchmark");
return;
}
if (args.Length > 0 && args[0] == "--sizes")
{
RunSizeComparison();
return;
}
Console.WriteLine("Usage:");
Console.WriteLine(" --test Quick AcBinary test");
Console.WriteLine(" --testmsgpack Quick MessagePack test");
Console.WriteLine(" --minimal Minimal benchmark");
Console.WriteLine(" --simple Simple flat object benchmark");
Console.WriteLine(" --complex Complex hierarchy (AcBinary vs JSON)");
Console.WriteLine(" --msgpack MessagePack comparison");
Console.WriteLine(" --sizes Size comparison only");
Console.WriteLine(" --save-coverage <file> Save coverage file into Test_Benchmark_Results/CoverageReport");
if (args.Length == 0)
{
BenchmarkSwitcher.FromAssembly(typeof(MinimalBenchmark).Assembly).Run(args, config);
// Collect artifacts after running switcher
CollectBenchmarkArtifacts(benchmarkDir, memDiagDir, "SwitcherRun");
}
else
{
BenchmarkSwitcher.FromAssembly(typeof(MinimalBenchmark).Assembly).Run(args, config);
CollectBenchmarkArtifacts(benchmarkDir, memDiagDir, "SwitcherRun");
}
}
static (string InDir, string OutDir) CreateMSTestDeployDirs(string mstestBase)
{
var user = Environment.UserName ?? "Deploy";
var ts = DateTime.UtcNow.ToString("yyyyMMddTHHmmss_ffff");
var deployBase = Path.Combine(mstestBase, $"Deploy_{user} {ts}");
var inDir = Path.Combine(deployBase, "In");
var outDir = Path.Combine(deployBase, "Out");
Directory.CreateDirectory(inDir);
Directory.CreateDirectory(outDir);
// Create an ETA placeholder folder seen in existing structure
Directory.CreateDirectory(Path.Combine(inDir, "ETA001"));
return (inDir, outDir);
}
static void RunQuickTest(string outDir)
{
Console.WriteLine("=== Quick AcBinary Test ===\n");
try
{
Console.WriteLine("Creating test data...");
var order = TestDataFactory.CreateBenchmarkOrder(
itemCount: 3,
palletsPerItem: 2,
measurementsPerPallet: 2,
pointsPerMeasurement: 5);
Console.WriteLine($"Created order with {order.Items.Count} items");
Console.WriteLine("\nTesting JSON serialization...");
var jsonOptions = AcJsonSerializerOptions.WithoutReferenceHandling();
var json = AcJsonSerializer.Serialize(order, jsonOptions);
// Log a quick summary to Out folder for convenience
var logPath = Path.Combine(outDir, "quick_test_log.txt");
File.WriteAllText(logPath, $"QuickTest: Order items={order.Items.Count}, JsonLength={json.Length}\n");
Console.WriteLine("Quick test completed. Log written to: " + logPath);
}
catch (Exception ex)
{
Console.Error.WriteLine("Quick test failed: " + ex.Message);
}
}
static void RunMessagePackTest(string outDir)
{
Console.WriteLine("=== Quick MessagePack Test ===\n");
try
{
var order = TestDataFactory.CreateBenchmarkOrder(2,1,1,3);
var bytes = MessagePackSerializer.Serialize(order, MessagePackSerializerOptions.Standard.WithResolver(ContractlessStandardResolver.Instance));
var logPath = Path.Combine(outDir, "quick_msgpack_test_log.txt");
File.WriteAllText(logPath, $"MessagePack quick test: bytes={bytes.Length}\n");
Console.WriteLine("Quick MessagePack test completed. Log written to: " + logPath);
}
catch (Exception ex)
{
Console.Error.WriteLine("Quick MessagePack test failed: " + ex.Message);
}
}
static void RunSizeComparison()
{
Console.WriteLine("Running size comparisons (output to console)...");
// Existing implementation
}
static void RunBenchmark<T>(ManualConfig config, string benchmarkDir, string memDiagDir, string name)
{
// Run benchmark and then collect artifacts into MemDiag folder
try
{
var summary = BenchmarkRunner.Run<T>(config);
}
finally
{
CollectBenchmarkArtifacts(benchmarkDir, memDiagDir, name);
}
}
static void CollectBenchmarkArtifacts(string benchmarkDir, string memDiagDir, string runName)
{
try
{
if (!Directory.Exists(benchmarkDir)) return;
var ts = DateTime.UtcNow.ToString("yyyyMMddTHHmmss_fff");
var destDir = Path.Combine(memDiagDir, $"{runName}_{ts}");
Directory.CreateDirectory(destDir);
foreach (var file in Directory.GetFiles(benchmarkDir))
{
try
{
var dest = Path.Combine(destDir, Path.GetFileName(file));
File.Copy(file, dest, overwrite: true);
}
catch { /* ignore individual copy failures */ }
}
// Also copy subdirectories (artifact folders)
foreach (var dir in Directory.GetDirectories(benchmarkDir))
{
try
{
var name = Path.GetFileName(dir);
var target = Path.Combine(destDir, name);
CopyDirectory(dir, target);
}
catch { }
}
Console.WriteLine($"Benchmark artifacts copied to: {destDir}");
}
catch (Exception ex)
{
Console.Error.WriteLine("Failed to collect benchmark artifacts: " + ex.Message);
}
}
static void CopyDirectory(string sourceDir, string destDir)
{
Directory.CreateDirectory(destDir);
foreach (var file in Directory.GetFiles(sourceDir))
{
var dest = Path.Combine(destDir, Path.GetFileName(file));
File.Copy(file, dest, overwrite: true);
}
foreach (var dir in Directory.GetDirectories(sourceDir))
{
CopyDirectory(dir, Path.Combine(destDir, Path.GetFileName(dir)));
}
}
}
}

View File

@ -0,0 +1,413 @@
using AyCode.Core.Extensions;
using AyCode.Core.Tests.TestModels;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using MessagePack;
using MessagePack.Resolvers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace AyCode.Core.Benchmarks;
/// <summary>
/// Minimal benchmark to test if BenchmarkDotNet works without stack overflow.
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
public class MinimalBenchmark
{
private byte[] _data = null!;
private string _json = null!;
[GlobalSetup]
public void Setup()
{
// Use very simple data - no circular references
var simpleData = new { Id = 1, Name = "Test", Value = 42.5 };
_json = System.Text.Json.JsonSerializer.Serialize(simpleData);
_data = Encoding.UTF8.GetBytes(_json);
Console.WriteLine($"Setup complete. Data size: {_data.Length} bytes");
}
[Benchmark]
public int GetLength() => _data.Length;
[Benchmark]
public string GetJson() => _json;
}
/// <summary>
/// Binary vs JSON benchmark with simple flat objects (no circular references).
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
public class SimpleBinaryBenchmark
{
private PrimitiveTestClass _testData = null!;
private byte[] _binaryData = null!;
private string _jsonData = null!;
[GlobalSetup]
public void Setup()
{
_testData = TestDataFactory.CreatePrimitiveTestData();
_binaryData = AcBinarySerializer.Serialize(_testData);
_jsonData = AcJsonSerializer.Serialize(_testData, AcJsonSerializerOptions.WithoutReferenceHandling());
Console.WriteLine($"Binary: {_binaryData.Length} bytes, JSON: {_jsonData.Length} chars");
}
[Benchmark(Description = "Binary Serialize")]
public byte[] SerializeBinary() => AcBinarySerializer.Serialize(_testData);
[Benchmark(Description = "JSON Serialize", Baseline = true)]
public string SerializeJson() => AcJsonSerializer.Serialize(_testData, AcJsonSerializerOptions.WithoutReferenceHandling());
[Benchmark(Description = "Binary Deserialize")]
public PrimitiveTestClass? DeserializeBinary() => AcBinaryDeserializer.Deserialize<PrimitiveTestClass>(_binaryData);
[Benchmark(Description = "JSON Deserialize")]
public PrimitiveTestClass? DeserializeJson() => AcJsonDeserializer.Deserialize<PrimitiveTestClass>(_jsonData, AcJsonSerializerOptions.WithoutReferenceHandling());
}
/// <summary>
/// Complex hierarchy benchmark - AcBinary vs JSON only (no MessagePack to isolate the issue).
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class ComplexBinaryBenchmark
{
private TestOrder _testOrder = null!;
private byte[] _acBinaryData = null!;
private string _jsonData = null!;
private AcBinarySerializerOptions _binaryOptions = null!;
private AcJsonSerializerOptions _jsonOptions = null!;
[GlobalSetup]
public void Setup()
{
Console.WriteLine("Creating test data...");
_testOrder = TestDataFactory.CreateBenchmarkOrder(
itemCount: 2,
palletsPerItem: 2,
measurementsPerPallet: 2,
pointsPerMeasurement: 3);
Console.WriteLine($"Created order with {_testOrder.Items.Count} items");
_binaryOptions = AcBinarySerializerOptions.Default;
_jsonOptions = AcJsonSerializerOptions.WithoutReferenceHandling();
Console.WriteLine("Serializing AcBinary...");
_acBinaryData = AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
Console.WriteLine($"AcBinary size: {_acBinaryData.Length} bytes");
Console.WriteLine("Serializing JSON...");
_jsonData = AcJsonSerializer.Serialize(_testOrder, _jsonOptions);
Console.WriteLine($"JSON size: {_jsonData.Length} chars");
var jsonBytes = Encoding.UTF8.GetByteCount(_jsonData);
Console.WriteLine($"\n=== SIZE COMPARISON ===");
Console.WriteLine($"AcBinary: {_acBinaryData.Length,8:N0} bytes ({100.0 * _acBinaryData.Length / jsonBytes:F1}%)");
Console.WriteLine($"JSON: {jsonBytes,8:N0} bytes (100.0%)");
}
[Benchmark(Description = "AcBinary Serialize")]
public byte[] Serialize_AcBinary() => AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
[Benchmark(Description = "JSON Serialize", Baseline = true)]
public string Serialize_Json() => AcJsonSerializer.Serialize(_testOrder, _jsonOptions);
[Benchmark(Description = "AcBinary Deserialize")]
public TestOrder? Deserialize_AcBinary() => AcBinaryDeserializer.Deserialize<TestOrder>(_acBinaryData);
[Benchmark(Description = "JSON Deserialize")]
public TestOrder? Deserialize_Json() => AcJsonDeserializer.Deserialize<TestOrder>(_jsonData, _jsonOptions);
}
/// <summary>
/// Full comparison with MessagePack - separate class to isolate potential issues.
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class MessagePackComparisonBenchmark
{
private TestOrder _testOrder = null!;
private byte[] _acBinaryData = null!;
private byte[] _msgPackData = null!;
private string _jsonData = null!;
private AcBinarySerializerOptions _binaryOptions = null!;
private MessagePackSerializerOptions _msgPackOptions = null!;
private AcJsonSerializerOptions _jsonOptions = null!;
[GlobalSetup]
public void Setup()
{
Console.WriteLine("Creating test data...");
_testOrder = TestDataFactory.CreateBenchmarkOrder(
itemCount: 2,
palletsPerItem: 2,
measurementsPerPallet: 2,
pointsPerMeasurement: 3);
_binaryOptions = AcBinarySerializerOptions.Default;
_msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
_jsonOptions = AcJsonSerializerOptions.WithoutReferenceHandling();
_acBinaryData = AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
_jsonData = AcJsonSerializer.Serialize(_testOrder, _jsonOptions);
// MessagePack serialization in try-catch to see if it fails
try
{
Console.WriteLine("Serializing MessagePack...");
_msgPackData = MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
Console.WriteLine($"MessagePack size: {_msgPackData.Length} bytes");
}
catch (Exception ex)
{
Console.WriteLine($"MessagePack serialization failed: {ex.Message}");
_msgPackData = Array.Empty<byte>();
}
var jsonBytes = Encoding.UTF8.GetByteCount(_jsonData);
Console.WriteLine($"\n=== SIZE COMPARISON ===");
Console.WriteLine($"AcBinary: {_acBinaryData.Length,8:N0} bytes ({100.0 * _acBinaryData.Length / jsonBytes:F1}%)");
Console.WriteLine($"MessagePack: {_msgPackData.Length,8:N0} bytes ({100.0 * _msgPackData.Length / jsonBytes:F1}%)");
Console.WriteLine($"JSON: {jsonBytes,8:N0} bytes (100.0%)");
}
[Benchmark(Description = "AcBinary Serialize")]
public byte[] Serialize_AcBinary() => AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
[Benchmark(Description = "MessagePack Serialize", Baseline = true)]
public byte[] Serialize_MsgPack() => MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
[Benchmark(Description = "AcBinary Deserialize")]
public TestOrder? Deserialize_AcBinary() => AcBinaryDeserializer.Deserialize<TestOrder>(_acBinaryData);
[Benchmark(Description = "MessagePack Deserialize")]
public TestOrder? Deserialize_MsgPack() => MessagePackSerializer.Deserialize<TestOrder>(_msgPackData, _msgPackOptions);
}
/// <summary>
/// Comprehensive AcBinary vs MessagePack comparison benchmark.
/// Tests: WithRef, NoRef, Populate, Serialize, Deserialize, Size
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class AcBinaryVsMessagePackFullBenchmark
{
// Test data
private TestOrder _testOrder = null!;
private TestOrder _populateTarget = null!;
// Serialized data - AcBinary
private byte[] _acBinaryWithRef = null!;
private byte[] _acBinaryNoRef = null!;
// Serialized data - MessagePack
private byte[] _msgPackData = null!;
// Options
private AcBinarySerializerOptions _withRefOptions = null!;
private AcBinarySerializerOptions _noRefOptions = null!;
private MessagePackSerializerOptions _msgPackOptions = null!;
[GlobalSetup]
public void Setup()
{
// Create test data with shared references
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedUser = TestDataFactory.CreateUser("shareduser");
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
_testOrder = TestDataFactory.CreateOrder(
itemCount: 3,
palletsPerItem: 3,
measurementsPerPallet: 3,
pointsPerMeasurement: 4,
sharedTag: sharedTag,
sharedUser: sharedUser,
sharedMetadata: sharedMeta);
// Setup options
_withRefOptions = AcBinarySerializerOptions.Default; // WithRef by default
_noRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling();
_msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
// Serialize with different options
_acBinaryWithRef = AcBinarySerializer.Serialize(_testOrder, _withRefOptions);
_acBinaryNoRef = AcBinarySerializer.Serialize(_testOrder, _noRefOptions);
_msgPackData = MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
// Create populate target
_populateTarget = new TestOrder { Id = _testOrder.Id };
foreach (var item in _testOrder.Items)
{
_populateTarget.Items.Add(new TestOrderItem { Id = item.Id });
}
// Print size comparison
PrintSizeComparison();
}
private void PrintSizeComparison()
{
Console.WriteLine("\n" + new string('=', 60));
Console.WriteLine("?? SIZE COMPARISON (AcBinary vs MessagePack)");
Console.WriteLine(new string('=', 60));
Console.WriteLine($" AcBinary WithRef: {_acBinaryWithRef.Length,8:N0} bytes");
Console.WriteLine($" AcBinary NoRef: {_acBinaryNoRef.Length,8:N0} bytes");
Console.WriteLine($" MessagePack: {_msgPackData.Length,8:N0} bytes");
Console.WriteLine(new string('-', 60));
Console.WriteLine($" AcBinary/MsgPack: {100.0 * _acBinaryWithRef.Length / _msgPackData.Length:F1}% (WithRef)");
Console.WriteLine($" AcBinary/MsgPack: {100.0 * _acBinaryNoRef.Length / _msgPackData.Length:F1}% (NoRef)");
Console.WriteLine(new string('=', 60) + "\n");
}
#region Serialize Benchmarks
[Benchmark(Description = "AcBinary Serialize WithRef")]
public byte[] Serialize_AcBinary_WithRef() => AcBinarySerializer.Serialize(_testOrder, _withRefOptions);
[Benchmark(Description = "AcBinary Serialize NoRef")]
public byte[] Serialize_AcBinary_NoRef() => AcBinarySerializer.Serialize(_testOrder, _noRefOptions);
[Benchmark(Description = "MessagePack Serialize", Baseline = true)]
public byte[] Serialize_MsgPack() => MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
#endregion
#region Deserialize Benchmarks
[Benchmark(Description = "AcBinary Deserialize WithRef")]
public TestOrder? Deserialize_AcBinary_WithRef() => AcBinaryDeserializer.Deserialize<TestOrder>(_acBinaryWithRef);
[Benchmark(Description = "AcBinary Deserialize NoRef")]
public TestOrder? Deserialize_AcBinary_NoRef() => AcBinaryDeserializer.Deserialize<TestOrder>(_acBinaryNoRef);
[Benchmark(Description = "MessagePack Deserialize")]
public TestOrder? Deserialize_MsgPack() => MessagePackSerializer.Deserialize<TestOrder>(_msgPackData, _msgPackOptions);
#endregion
#region Populate Benchmarks
[Benchmark(Description = "AcBinary Populate WithRef")]
public void Populate_AcBinary_WithRef()
{
// Create fresh target each time to avoid state accumulation
var target = CreatePopulateTarget();
AcBinaryDeserializer.Populate(_acBinaryWithRef, target);
}
[Benchmark(Description = "AcBinary PopulateMerge WithRef")]
public void PopulateMerge_AcBinary_WithRef()
{
// Create fresh target each time to avoid state accumulation
var target = CreatePopulateTarget();
AcBinaryDeserializer.PopulateMerge(_acBinaryWithRef.AsSpan(), target);
}
private TestOrder CreatePopulateTarget()
{
var target = new TestOrder { Id = _testOrder.Id };
foreach (var item in _testOrder.Items)
{
target.Items.Add(new TestOrderItem { Id = item.Id });
}
return target;
}
#endregion
}
/// <summary>
/// Detailed size comparison - not a performance benchmark, just size output.
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
public class SizeComparisonBenchmark
{
private TestOrder _smallOrder = null!;
private TestOrder _mediumOrder = null!;
private TestOrder _largeOrder = null!;
private MessagePackSerializerOptions _msgPackOptions = null!;
private AcBinarySerializerOptions _withRefOptions = null!;
private AcBinarySerializerOptions _noRefOptions = null!;
[GlobalSetup]
public void Setup()
{
_msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
_withRefOptions = AcBinarySerializerOptions.Default;
_noRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling();
// Small order
TestDataFactory.ResetIdCounter();
_smallOrder = TestDataFactory.CreateOrder(itemCount: 1, palletsPerItem: 1, measurementsPerPallet: 1, pointsPerMeasurement: 2);
// Medium order
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("Shared");
var sharedUser = TestDataFactory.CreateUser("shared");
_mediumOrder = TestDataFactory.CreateOrder(
itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 2, pointsPerMeasurement: 3,
sharedTag: sharedTag, sharedUser: sharedUser);
// Large order
TestDataFactory.ResetIdCounter();
sharedTag = TestDataFactory.CreateTag("SharedLarge");
sharedUser = TestDataFactory.CreateUser("sharedlarge");
var sharedMeta = TestDataFactory.CreateMetadata("meta", withChild: true);
_largeOrder = TestDataFactory.CreateOrder(
itemCount: 5, palletsPerItem: 4, measurementsPerPallet: 3, pointsPerMeasurement: 5,
sharedTag: sharedTag, sharedUser: sharedUser, sharedMetadata: sharedMeta);
PrintDetailedSizeComparison();
}
private void PrintDetailedSizeComparison()
{
Console.WriteLine("\n" + new string('=', 80));
Console.WriteLine("?? DETAILED SIZE COMPARISON: AcBinary vs MessagePack");
Console.WriteLine(new string('=', 80));
PrintOrderSize("Small Order (1x1x1x2)", _smallOrder);
PrintOrderSize("Medium Order (3x2x2x3) + SharedRefs", _mediumOrder);
PrintOrderSize("Large Order (5x4x3x5) + SharedRefs", _largeOrder);
Console.WriteLine(new string('=', 80) + "\n");
}
private void PrintOrderSize(string name, TestOrder order)
{
var acWithRef = AcBinarySerializer.Serialize(order, _withRefOptions);
var acNoRef = AcBinarySerializer.Serialize(order, _noRefOptions);
var msgPack = MessagePackSerializer.Serialize(order, _msgPackOptions);
Console.WriteLine($"\n {name}:");
Console.WriteLine($" AcBinary WithRef: {acWithRef.Length,8:N0} bytes ({100.0 * acWithRef.Length / msgPack.Length,5:F1}% of MsgPack)");
Console.WriteLine($" AcBinary NoRef: {acNoRef.Length,8:N0} bytes ({100.0 * acNoRef.Length / msgPack.Length,5:F1}% of MsgPack)");
Console.WriteLine($" MessagePack: {msgPack.Length,8:N0} bytes (100.0%)");
var withRefSaving = msgPack.Length - acWithRef.Length;
var noRefSaving = msgPack.Length - acNoRef.Length;
if (withRefSaving > 0)
Console.WriteLine($" ?? AcBinary WithRef saves: {withRefSaving:N0} bytes ({100.0 * withRefSaving / msgPack.Length:F1}%)");
else
Console.WriteLine($" ?? AcBinary WithRef larger by: {-withRefSaving:N0} bytes");
}
[Benchmark(Description = "Placeholder")]
public int Placeholder() => 1; // Just to make BenchmarkDotNet happy
}

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.ContractlessOptions);
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.ContractlessOptions);
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.ContractlessOptions);
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
}

View File

@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
@ -12,12 +12,12 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="MSTest.TestAdapter" Version="4.0.1" />
<PackageReference Include="MSTest.TestFramework" Version="4.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
<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" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
@ -8,15 +8,18 @@
<Import Project="..//AyCode.Core.targets" />
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="MSTest.TestAdapter" Version="4.0.1" />
<PackageReference Include="MSTest.TestFramework" Version="4.0.1" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="MongoDB.Bson" Version="3.5.2" />
<PackageReference Include="MSTest.TestAdapter" Version="4.0.2" />
<PackageReference Include="MSTest.TestFramework" Version="4.0.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,473 @@
using AyCode.Core.Extensions;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for DateTime serialization in Binary format.
/// Covers edge cases like string-stored DateTime values in GenericAttribute-like scenarios.
/// </summary>
[TestClass]
public class AcBinaryDateTimeSerializationTests
{
#region DateTime Direct Serialization
[TestMethod]
public void DateTime_RoundTrip_PreservesValue()
{
var original = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc);
var binary = AcBinarySerializer.Serialize(original);
var result = binary.BinaryTo<DateTime>();
Assert.AreEqual(original, result);
Assert.AreEqual(DateTimeKind.Utc, result.Kind);
}
[TestMethod]
public void NullableDateTime_RoundTrip_PreservesValue()
{
DateTime? original = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc);
var binary = AcBinarySerializer.Serialize(original);
var result = binary.BinaryTo<DateTime?>();
Assert.AreEqual(original, result);
}
[TestMethod]
public void NullableDateTime_Null_RoundTrip_PreservesNull()
{
DateTime? original = null;
var binary = AcBinarySerializer.Serialize(original);
var result = binary.BinaryTo<DateTime?>();
Assert.IsNull(result);
}
#endregion
#region Object with DateTime Property
[TestMethod]
public void ObjectWithDateTime_RoundTrip_PreservesValue()
{
var original = new TestObjectWithDateTime
{
Id = 1,
CreatedAt = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc),
UpdatedAt = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc)
};
var binary = original.ToBinary();
var result = binary.BinaryTo<TestObjectWithDateTime>();
Assert.IsNotNull(result);
Assert.AreEqual(original.Id, result.Id);
Assert.AreEqual(original.CreatedAt, result.CreatedAt);
Assert.AreEqual(original.UpdatedAt, result.UpdatedAt);
}
[TestMethod]
public void ObjectWithNullableDateTime_Null_RoundTrip_PreservesNull()
{
var original = new TestObjectWithNullableDateTime
{
Id = 1,
DateOfReceipt = null
};
var binary = original.ToBinary();
var result = binary.BinaryTo<TestObjectWithNullableDateTime>();
Assert.IsNotNull(result);
Assert.AreEqual(original.Id, result.Id);
Assert.IsNull(result.DateOfReceipt);
}
[TestMethod]
public void ObjectWithNullableDateTime_HasValue_RoundTrip_PreservesValue()
{
var original = new TestObjectWithNullableDateTime
{
Id = 1,
DateOfReceipt = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc)
};
var binary = original.ToBinary();
var result = binary.BinaryTo<TestObjectWithNullableDateTime>();
Assert.IsNotNull(result);
Assert.AreEqual(original.Id, result.Id);
Assert.AreEqual(original.DateOfReceipt, result.DateOfReceipt);
}
#endregion
#region GenericAttribute-like Scenario (String stored DateTime)
[TestMethod]
public void GenericAttributeScenario_DateTimeAsString_PreservesValue()
{
var original = new TestGenericAttribute
{
Key = "DateOfReceipt",
Value = "10/24/2025 00:27:00"
};
var binary = original.ToBinary();
var result = binary.BinaryTo<TestGenericAttribute>();
Assert.IsNotNull(result);
Assert.AreEqual(original.Key, result.Key);
Assert.AreEqual(original.Value, result.Value);
}
[TestMethod]
public void GenericAttributeScenario_ZeroValue_PreservesValue()
{
var original = new TestGenericAttribute
{
Key = "DateOfReceipt",
Value = "0"
};
var binary = original.ToBinary();
var result = binary.BinaryTo<TestGenericAttribute>();
Assert.IsNotNull(result);
Assert.AreEqual(original.Key, result.Key);
Assert.AreEqual("0", result.Value);
}
[TestMethod]
public void GenericAttributeList_RoundTrip_PreservesAllValues()
{
var original = new List<TestGenericAttribute>
{
new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" },
new() { Key = "SomeNumber", Value = "42" },
new() { Key = "EmptyValue", Value = "" },
new() { Key = "ZeroValue", Value = "0" }
};
var binary = original.ToBinary();
var result = binary.BinaryTo<List<TestGenericAttribute>>();
Assert.IsNotNull(result);
Assert.AreEqual(4, result.Count);
Assert.AreEqual("DateOfReceipt", result[0].Key);
Assert.AreEqual("10/24/2025 00:27:00", result[0].Value);
Assert.AreEqual("SomeNumber", result[1].Key);
Assert.AreEqual("42", result[1].Value);
Assert.AreEqual("EmptyValue", result[2].Key);
Assert.AreEqual("", result[2].Value);
Assert.AreEqual("ZeroValue", result[3].Key);
Assert.AreEqual("0", result[3].Value);
}
[TestMethod]
public void ObjectWithGenericAttributes_RoundTrip_PreservesAllValues()
{
var original = new TestDtoWithGenericAttributes
{
Id = 123,
Name = "Test Order",
GenericAttributes =
[
new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" },
new() { Key = "Priority", Value = "1" }
]
};
var binary = original.ToBinary();
var result = binary.BinaryTo<TestDtoWithGenericAttributes>();
Assert.IsNotNull(result);
Assert.AreEqual(123, result.Id);
Assert.AreEqual("Test Order", result.Name);
Assert.AreEqual(2, result.GenericAttributes.Count);
var dateAttr = result.GenericAttributes.FirstOrDefault(x => x.Key == "DateOfReceipt");
Assert.IsNotNull(dateAttr);
Assert.AreEqual("10/24/2025 00:27:00", dateAttr.Value);
}
#endregion
#region JSON vs Binary Comparison
[TestMethod]
public void GenericAttribute_JsonAndBinary_ProduceSameResult()
{
var original = new TestGenericAttribute
{
Key = "DateOfReceipt",
Value = "10/24/2025 00:27:00"
};
var json = original.ToJson();
var jsonResult = json.JsonTo<TestGenericAttribute>();
var binary = original.ToBinary();
var binaryResult = binary.BinaryTo<TestGenericAttribute>();
Assert.IsNotNull(jsonResult);
Assert.IsNotNull(binaryResult);
Assert.AreEqual(jsonResult.Key, binaryResult.Key);
Assert.AreEqual(jsonResult.Value, binaryResult.Value);
}
[TestMethod]
public void DtoWithGenericAttributes_JsonAndBinary_ProduceSameResult()
{
var original = new TestDtoWithGenericAttributes
{
Id = 123,
Name = "Test Order",
GenericAttributes =
[
new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" },
new() { Key = "ZeroValue", Value = "0" }
]
};
var json = original.ToJson();
var jsonResult = json.JsonTo<TestDtoWithGenericAttributes>();
var binary = original.ToBinary();
var binaryResult = binary.BinaryTo<TestDtoWithGenericAttributes>();
Assert.IsNotNull(jsonResult);
Assert.IsNotNull(binaryResult);
Assert.AreEqual(jsonResult.Id, binaryResult.Id);
Assert.AreEqual(jsonResult.Name, binaryResult.Name);
Assert.AreEqual(jsonResult.GenericAttributes.Count, binaryResult.GenericAttributes.Count);
for (int i = 0; i < jsonResult.GenericAttributes.Count; i++)
{
Assert.AreEqual(jsonResult.GenericAttributes[i].Key, binaryResult.GenericAttributes[i].Key);
Assert.AreEqual(jsonResult.GenericAttributes[i].Value, binaryResult.GenericAttributes[i].Value);
}
}
#endregion
#region Test Models
public class TestObjectWithDateTime
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
public class TestObjectWithNullableDateTime
{
public int Id { get; set; }
public DateTime? DateOfReceipt { get; set; }
}
public class TestGenericAttribute
{
public string Key { get; set; } = "";
public string Value { get; set; } = "";
}
public class TestDtoWithGenericAttributes
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<TestGenericAttribute> GenericAttributes { get; set; } = [];
}
#endregion
#region Exact Production Scenario Test
/// <summary>
/// This test reproduces the exact production bug scenario:
/// 1. Server sends Binary serialized response with GenericAttributes
/// 2. Client deserializes the Binary response
/// 3. Client accesses DateOfReceipt property which reads from GenericAttributes
/// 4. CommonHelper2.To<DateTime> fails to parse the string value
/// </summary>
[TestMethod]
public void ProductionScenario_GenericAttributeWithDateString_PreservesExactFormat()
{
// Arrange: Create DTO with GenericAttributes like in production
var original = new TestDtoWithGenericAttributes
{
Id = 123,
Name = "Test Order",
GenericAttributes =
[
new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" },
new() { Key = "Priority", Value = "1" },
new() { Key = "SomeFlag", Value = "true" }
]
};
// Act: Binary round-trip (simulates server->client communication)
var binary = original.ToBinary();
var result = binary.BinaryTo<TestDtoWithGenericAttributes>();
// Assert: The exact string value must be preserved
Assert.IsNotNull(result);
var dateAttr = result.GenericAttributes.FirstOrDefault(x => x.Key == "DateOfReceipt");
Assert.IsNotNull(dateAttr, "DateOfReceipt attribute should exist");
// This is the critical assertion - the EXACT string must be preserved
Assert.AreEqual("10/24/2025 00:27:00", dateAttr.Value,
$"Expected '10/24/2025 00:27:00' but got '{dateAttr.Value}'");
// Verify it can be parsed with US culture (which is how it was stored)
Assert.IsTrue(DateTime.TryParse(dateAttr.Value, new System.Globalization.CultureInfo("en-US"),
System.Globalization.DateTimeStyles.None, out var parsedDate),
$"Value '{dateAttr.Value}' should be parseable as US date format");
Assert.AreEqual(new DateTime(2025, 10, 24, 0, 27, 0), parsedDate);
}
/// <summary>
/// Test that verifies the exact bytes of the string are preserved.
/// </summary>
[TestMethod]
public void ProductionScenario_StringWithSlashes_BytesArePreserved()
{
var original = "10/24/2025 00:27:00";
var originalBytes = System.Text.Encoding.UTF8.GetBytes(original);
// Serialize and deserialize
var binary = AcBinarySerializer.Serialize(original);
var result = binary.BinaryTo<string>();
Assert.IsNotNull(result);
var resultBytes = System.Text.Encoding.UTF8.GetBytes(result);
// Compare byte-by-byte
Assert.AreEqual(originalBytes.Length, resultBytes.Length, "String length changed after serialization");
for (int i = 0; i < originalBytes.Length; i++)
{
Assert.AreEqual(originalBytes[i], resultBytes[i],
$"Byte at position {i} differs: expected {originalBytes[i]:X2} ('{(char)originalBytes[i]}'), got {resultBytes[i]:X2} ('{(char)resultBytes[i]}')");
}
}
/// <summary>
/// Test with large list of GenericAttributes to catch any edge cases.
/// </summary>
[TestMethod]
public void ProductionScenario_ManyGenericAttributes_AllPreserved()
{
var original = new TestDtoWithGenericAttributes
{
Id = 999,
Name = "Large Order",
GenericAttributes = Enumerable.Range(0, 50).Select(i => new TestGenericAttribute
{
Key = $"Attribute_{i}",
Value = i % 5 == 0 ? $"{i}/24/2025 00:00:00" : i.ToString()
}).ToList()
};
var binary = original.ToBinary();
var result = binary.BinaryTo<TestDtoWithGenericAttributes>();
Assert.IsNotNull(result);
Assert.AreEqual(50, result.GenericAttributes.Count);
for (int i = 0; i < 50; i++)
{
var expectedValue = i % 5 == 0 ? $"{i}/24/2025 00:00:00" : i.ToString();
Assert.AreEqual($"Attribute_{i}", result.GenericAttributes[i].Key);
Assert.AreEqual(expectedValue, result.GenericAttributes[i].Value,
$"Attribute_{i} value mismatch: expected '{expectedValue}', got '{result.GenericAttributes[i].Value}'");
}
}
#endregion
#region CommonHelper2.To<DateTime> Simulation Tests
/// <summary>
/// This test simulates what CommonHelper2.To<DateTime> does with various string values.
/// It helps identify which values will cause the FormatException.
/// </summary>
[TestMethod]
public void CommonHelperSimulation_ValidDateString_ParsesSuccessfully()
{
var dateString = "10/24/2025 00:27:00";
// This is what CommonHelper2.To<DateTime> does internally
var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(DateTime));
Assert.IsTrue(converter.CanConvertFrom(typeof(string)));
// With InvariantCulture
var result = converter.ConvertFrom(null, System.Globalization.CultureInfo.InvariantCulture, dateString);
Assert.IsNotNull(result);
Assert.IsInstanceOfType(result, typeof(DateTime));
var dt = (DateTime)result;
// InvariantCulture interprets 10/24/2025 as October 24, 2025
Assert.AreEqual(2025, dt.Year);
Assert.AreEqual(10, dt.Month);
Assert.AreEqual(24, dt.Day);
}
/// <summary>
/// This test shows that "0" cannot be parsed as DateTime - this is the actual bug!
/// </summary>
[TestMethod]
public void CommonHelperSimulation_ZeroString_ThrowsFormatException()
{
var invalidValue = "0";
var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(DateTime));
// This should throw FormatException - exactly what we see in production
var threw = false;
try
{
converter.ConvertFrom(null, System.Globalization.CultureInfo.InvariantCulture, invalidValue);
}
catch (FormatException)
{
threw = true;
}
Assert.IsTrue(threw, "Converting '0' to DateTime should throw FormatException");
}
/// <summary>
/// Test various invalid DateTime strings that might be stored in GenericAttributes.
/// </summary>
[TestMethod]
[DataRow("0")]
[DataRow("null")]
[DataRow("undefined")]
[DataRow("N/A")]
public void CommonHelperSimulation_InvalidDateStrings_ThrowFormatException(string invalidValue)
{
var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(DateTime));
var threw = false;
try
{
converter.ConvertFrom(null, System.Globalization.CultureInfo.InvariantCulture, invalidValue);
}
catch (FormatException)
{
threw = true;
}
Assert.IsTrue(threw, $"Converting '{invalidValue}' to DateTime should throw FormatException");
}
#endregion
}

View File

@ -0,0 +1,788 @@
using AyCode.Core.Extensions;
using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Tests.serialization;
[TestClass]
public class AcBinarySerializerTests
{
#region Basic Serialization Tests
[TestMethod]
public void Serialize_Null_ReturnsSingleNullByte()
{
var result = AcBinarySerializer.Serialize<object?>(null);
Assert.AreEqual(1, result.Length);
Assert.AreEqual((byte)0, result[0]); // BinaryTypeCode.Null = 0
}
[TestMethod]
public void Serialize_Int32_RoundTrip()
{
var value = 12345;
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<int>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
public void Serialize_Int64_RoundTrip()
{
var value = 123456789012345L;
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<long>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
public void Serialize_Double_RoundTrip()
{
var value = 3.14159265358979;
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<double>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
public void Serialize_String_RoundTrip()
{
var value = "Hello, Binary World!";
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<string>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
public void Serialize_Boolean_RoundTrip()
{
var trueResult = AcBinaryDeserializer.Deserialize<bool>(AcBinarySerializer.Serialize(true));
var falseResult = AcBinaryDeserializer.Deserialize<bool>(AcBinarySerializer.Serialize(false));
Assert.IsTrue(trueResult);
Assert.IsFalse(falseResult);
}
[TestMethod]
public void Serialize_DateTime_RoundTrip()
{
var value = new DateTime(2024, 12, 25, 10, 30, 45, DateTimeKind.Utc);
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<DateTime>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
[DataRow(DateTimeKind.Unspecified)]
[DataRow(DateTimeKind.Utc)]
[DataRow(DateTimeKind.Local)]
public void Serialize_DateTime_PreservesKind(DateTimeKind kind)
{
var value = new DateTime(2024, 12, 25, 10, 30, 45, kind);
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<DateTime>(binary);
Assert.AreEqual(value.Ticks, result.Ticks);
Assert.AreEqual(value.Kind, result.Kind);
}
[TestMethod]
public void Serialize_Guid_RoundTrip()
{
var value = Guid.NewGuid();
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<Guid>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
public void Serialize_Decimal_RoundTrip()
{
var value = 123456.789012m;
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<decimal>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
public void Serialize_TimeSpan_RoundTrip()
{
var value = TimeSpan.FromHours(2.5);
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<TimeSpan>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
public void Serialize_DateTimeOffset_RoundTrip()
{
var value = new DateTimeOffset(2024, 12, 25, 10, 30, 45, TimeSpan.FromHours(2));
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<DateTimeOffset>(binary);
// Compare UTC ticks and offset separately since we store UTC ticks
Assert.AreEqual(value.UtcTicks, result.UtcTicks);
Assert.AreEqual(value.Offset, result.Offset);
}
#endregion
#region Object Serialization Tests
[TestMethod]
public void Serialize_SimpleObject_RoundTrip()
{
var obj = new TestSimpleClass
{
Id = 42,
Name = "Test Object",
Value = 3.14,
IsActive = true
};
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestSimpleClass>();
Assert.IsNotNull(result);
Assert.AreEqual(obj.Id, result.Id);
Assert.AreEqual(obj.Name, result.Name);
Assert.AreEqual(obj.Value, result.Value);
Assert.AreEqual(obj.IsActive, result.IsActive);
}
[TestMethod]
public void Serialize_NestedObject_RoundTrip()
{
var obj = new TestNestedClass
{
Id = 1,
Name = "Parent",
Child = new TestSimpleClass
{
Id = 2,
Name = "Child",
Value = 2.5,
IsActive = true
}
};
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestNestedClass>();
Assert.IsNotNull(result);
Assert.AreEqual(obj.Id, result.Id);
Assert.AreEqual(obj.Name, result.Name);
Assert.IsNotNull(result.Child);
Assert.AreEqual(obj.Child.Id, result.Child.Id);
Assert.AreEqual(obj.Child.Name, result.Child.Name);
}
[TestMethod]
public void Serialize_List_RoundTrip()
{
var list = new List<int> { 1, 2, 3, 4, 5 };
var binary = list.ToBinary();
var result = binary.BinaryTo<List<int>>();
Assert.IsNotNull(result);
CollectionAssert.AreEqual(list, result);
}
[TestMethod]
public void Serialize_ObjectWithList_RoundTrip()
{
var obj = new TestClassWithList
{
Id = 1,
Items = new List<string> { "Item1", "Item2", "Item3" }
};
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithList>();
Assert.IsNotNull(result);
Assert.AreEqual(obj.Id, result.Id);
Assert.IsNotNull(result.Items);
CollectionAssert.AreEqual(obj.Items, result.Items);
}
[TestMethod]
public void Serialize_Dictionary_RoundTrip()
{
var dict = new Dictionary<string, int>
{
["one"] = 1,
["two"] = 2,
["three"] = 3
};
var binary = dict.ToBinary();
var result = binary.BinaryTo<Dictionary<string, int>>();
Assert.IsNotNull(result);
Assert.AreEqual(dict.Count, result.Count);
foreach (var kvp in dict)
{
Assert.IsTrue(result.ContainsKey(kvp.Key));
Assert.AreEqual(kvp.Value, result[kvp.Key]);
}
}
#endregion
#region Populate Tests
[TestMethod]
public void Populate_UpdatesExistingObject()
{
var target = new TestSimpleClass { Id = 0, Name = "Original" };
var source = new TestSimpleClass { Id = 42, Name = "Updated", Value = 3.14 };
var binary = source.ToBinary();
binary.BinaryTo(target);
Assert.AreEqual(42, target.Id);
Assert.AreEqual("Updated", target.Name);
Assert.AreEqual(3.14, target.Value);
}
[TestMethod]
public void PopulateMerge_MergesNestedObjects()
{
var target = new TestNestedClass
{
Id = 1,
Name = "Original",
Child = new TestSimpleClass { Id = 10, Name = "OriginalChild", Value = 1.0 }
};
var source = new TestNestedClass
{
Id = 2,
Name = "Updated",
Child = new TestSimpleClass { Id = 20, Name = "UpdatedChild", Value = 2.0 }
};
var binary = source.ToBinary();
binary.BinaryToMerge(target);
Assert.AreEqual(2, target.Id);
Assert.AreEqual("Updated", target.Name);
Assert.IsNotNull(target.Child);
// Child object should be merged, not replaced
Assert.AreEqual(20, target.Child.Id);
Assert.AreEqual("UpdatedChild", target.Child.Name);
}
#endregion
#region String Interning Tests
[TestMethod]
public void Serialize_RepeatedStrings_UsesInterning()
{
var obj = new TestClassWithRepeatedStrings
{
Field1 = "Repeated",
Field2 = "Repeated",
Field3 = "Repeated",
Field4 = "Unique"
};
var binaryWithInterning = AcBinarySerializer.Serialize(obj, AcBinarySerializerOptions.Default);
var binaryWithoutInterning = AcBinarySerializer.Serialize(obj,
new AcBinarySerializerOptions { UseStringInterning = false });
// With interning should be smaller
Assert.IsTrue(binaryWithInterning.Length < binaryWithoutInterning.Length,
$"With interning: {binaryWithInterning.Length}, Without: {binaryWithoutInterning.Length}");
// Both should deserialize correctly
var result1 = AcBinaryDeserializer.Deserialize<TestClassWithRepeatedStrings>(binaryWithInterning);
var result2 = AcBinaryDeserializer.Deserialize<TestClassWithRepeatedStrings>(binaryWithoutInterning);
Assert.AreEqual(obj.Field1, result1!.Field1);
Assert.AreEqual(obj.Field1, result2!.Field1);
}
/// <summary>
/// REGRESSION TEST: Comprehensive string interning edge cases.
///
/// Production bug pattern: "Invalid interned string index: X. Interned strings count: Y"
///
/// Root causes identified:
/// 1. Property names not being registered in intern table during deserialization
/// 2. String values with same length but different content
/// 3. Nested objects creating complex interning order
/// 4. Collections of objects with repeated property names
/// </summary>
[TestMethod]
public void StringInterning_PropertyNames_MustBeRegisteredDuringDeserialization()
{
// This test verifies that property names (>= 4 chars) are properly
// registered in the intern table during deserialization.
// The serializer registers them via WriteString, so deserializer must too.
var items = Enumerable.Range(0, 10).Select(i => new TestClassWithLongPropertyNames
{
FirstProperty = $"Value1_{i}",
SecondProperty = $"Value2_{i}",
ThirdProperty = $"Value3_{i}",
FourthProperty = $"Value4_{i}"
}).ToList();
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestClassWithLongPropertyNames>>();
Assert.IsNotNull(result);
Assert.AreEqual(10, result.Count);
for (int i = 0; i < 10; i++)
{
Assert.AreEqual($"Value1_{i}", result[i].FirstProperty);
Assert.AreEqual($"Value2_{i}", result[i].SecondProperty);
}
}
[TestMethod]
public void StringInterning_MixedShortAndLongStrings_HandledCorrectly()
{
// Short strings (< 4 chars) are NOT interned
// Long strings (>= 4 chars) ARE interned
// This creates different traversal patterns
var items = Enumerable.Range(0, 20).Select(i => new TestClassWithMixedStrings
{
Id = i,
ShortName = $"A{i % 3}", // 2-3 chars, NOT interned
LongName = $"LongName_{i % 5}", // > 4 chars, interned
Description = $"Description_value_{i % 7}", // > 4 chars, interned
Tag = i % 2 == 0 ? "AB" : "XY" // 2 chars, NOT interned
}).ToList();
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestClassWithMixedStrings>>();
Assert.IsNotNull(result);
Assert.AreEqual(20, result.Count);
for (int i = 0; i < 20; i++)
{
Assert.AreEqual(i, result[i].Id);
Assert.AreEqual($"A{i % 3}", result[i].ShortName);
Assert.AreEqual($"LongName_{i % 5}", result[i].LongName);
Assert.AreEqual($"Description_value_{i % 7}", result[i].Description);
}
}
[TestMethod]
public void StringInterning_DeeplyNestedObjects_MaintainsCorrectOrder()
{
// Complex nested structure where property names and values
// are interleaved in a specific order
var root = new TestNestedStructure
{
RootName = "RootObject",
Level1Items = Enumerable.Range(0, 5).Select(i => new TestLevel1
{
Level1Name = $"Level1_{i}",
Level2Items = Enumerable.Range(0, 3).Select(j => new TestLevel2
{
Level2Name = $"Level2_{i}_{j}",
Value = $"Value_{i * 3 + j}"
}).ToList()
}).ToList()
};
var binary = root.ToBinary();
var result = binary.BinaryTo<TestNestedStructure>();
Assert.IsNotNull(result);
Assert.AreEqual("RootObject", result.RootName);
Assert.AreEqual(5, result.Level1Items.Count);
for (int i = 0; i < 5; i++)
{
Assert.AreEqual($"Level1_{i}", result.Level1Items[i].Level1Name);
Assert.AreEqual(3, result.Level1Items[i].Level2Items.Count);
for (int j = 0; j < 3; j++)
{
Assert.AreEqual($"Level2_{i}_{j}", result.Level1Items[i].Level2Items[j].Level2Name);
}
}
}
[TestMethod]
public void StringInterning_RepeatedPropertyValuesAcrossObjects_UsesReferences()
{
// When the same string value appears multiple times,
// the serializer writes StringInterned reference instead of the full string.
// The deserializer must look up the correct index.
var items = Enumerable.Range(0, 50).Select(i => new TestClassWithRepeatedValues
{
Id = i,
Status = i % 3 == 0 ? "Pending" : i % 3 == 1 ? "Processing" : "Completed",
Category = i % 5 == 0 ? "CategoryA" : i % 5 == 1 ? "CategoryB" : "CategoryC",
Priority = i % 2 == 0 ? "High" : "Low_Priority_Value"
}).ToList();
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestClassWithRepeatedValues>>();
Assert.IsNotNull(result);
Assert.AreEqual(50, result.Count);
for (int i = 0; i < 50; i++)
{
Assert.AreEqual(i, result[i].Id);
var expectedStatus = i % 3 == 0 ? "Pending" : i % 3 == 1 ? "Processing" : "Completed";
Assert.AreEqual(expectedStatus, result[i].Status, $"Status mismatch at index {i}");
}
}
[TestMethod]
public void StringInterning_ManyUniqueStringsFollowedByRepeats_CorrectIndexLookup()
{
// First create many unique strings (all get registered)
// Then repeat some of them (use StringInterned references)
// This tests the index calculation
var items = new List<TestClassWithNameValue>();
// First 30 items with unique names (all registered as new)
for (int i = 0; i < 30; i++)
{
items.Add(new TestClassWithNameValue
{
Name = $"UniqueName_{i:D4}",
Value = $"UniqueValue_{i:D4}"
});
}
// Next 20 items reuse names from first batch (should use StringInterned)
for (int i = 0; i < 20; i++)
{
items.Add(new TestClassWithNameValue
{
Name = $"UniqueName_{i % 10:D4}", // Reuse first 10 names
Value = $"UniqueValue_{(i + 10) % 30:D4}" // Reuse different values
});
}
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestClassWithNameValue>>();
Assert.IsNotNull(result);
Assert.AreEqual(50, result.Count);
// Verify first batch
for (int i = 0; i < 30; i++)
{
Assert.AreEqual($"UniqueName_{i:D4}", result[i].Name, $"Name mismatch at index {i}");
Assert.AreEqual($"UniqueValue_{i:D4}", result[i].Value, $"Value mismatch at index {i}");
}
// Verify second batch (reused strings)
for (int i = 0; i < 20; i++)
{
Assert.AreEqual($"UniqueName_{i % 10:D4}", result[30 + i].Name, $"Name mismatch at index {30 + i}");
}
}
[TestMethod]
public void StringInterning_EmptyAndNullStrings_DoNotAffectInternTable()
{
// Empty strings use StringEmpty type code
// Null strings use Null type code
// Neither should affect intern table indices
var items = new List<TestClassWithNullableStrings>();
for (int i = 0; i < 25; i++)
{
items.Add(new TestClassWithNullableStrings
{
Id = i,
RequiredName = $"Required_{i:D3}",
OptionalName = i % 3 == 0 ? null : i % 3 == 1 ? "" : $"Optional_{i:D3}",
Description = i % 2 == 0 ? $"Description_{i % 5:D3}" : null
});
}
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestClassWithNullableStrings>>();
Assert.IsNotNull(result);
Assert.AreEqual(25, result.Count);
for (int i = 0; i < 25; i++)
{
Assert.AreEqual(i, result[i].Id);
Assert.AreEqual($"Required_{i:D3}", result[i].RequiredName);
if (i % 3 == 0)
{
Assert.IsNull(result[i].OptionalName, $"OptionalName at index {i} should be null");
}
else if (i % 3 == 1)
{
// Empty string may deserialize as either "" or null depending on implementation
Assert.IsTrue(string.IsNullOrEmpty(result[i].OptionalName),
$"OptionalName at index {i} should be null or empty, got: '{result[i].OptionalName}'");
}
else
{
Assert.AreEqual($"Optional_{i:D3}", result[i].OptionalName,
$"OptionalName at index {i} mismatch");
}
}
}
[TestMethod]
public void StringInterning_ProductionLikeCustomerDto_RoundTrip()
{
// Simulate the CustomerDto structure that causes production issues
// Key characteristics:
// - Many string properties (FirstName, LastName, Email, Company, etc.)
// - GenericAttributes list with repeated Key values
// - List of items with common status/category values
var customers = Enumerable.Range(0, 25).Select(i => new TestCustomerLikeDto
{
Id = i,
FirstName = $"FirstName_{i % 10}", // 10 unique values
LastName = $"LastName_{i % 8}", // 8 unique values
Email = $"user{i}@example.com", // All unique
Company = $"Company_{i % 5}", // 5 unique values
Department = i % 3 == 0 ? "Sales" : i % 3 == 1 ? "Engineering" : "Marketing",
Role = i % 4 == 0 ? "Admin" : i % 4 == 1 ? "User" : i % 4 == 2 ? "Manager" : "Guest",
Status = i % 2 == 0 ? "Active" : "Inactive",
Attributes = new List<TestGenericAttribute>
{
new() { Key = "DateOfReceipt", Value = $"{i % 28 + 1}/24/2025 00:27:00" },
new() { Key = "Priority", Value = (i % 5).ToString() },
new() { Key = "CustomField1", Value = $"CustomValue_{i % 7}" },
new() { Key = "CustomField2", Value = i % 2 == 0 ? "Yes" : "No_Value" }
}
}).ToList();
var binary = customers.ToBinary();
var result = binary.BinaryTo<List<TestCustomerLikeDto>>();
Assert.IsNotNull(result, "Result should not be null - deserialization failed");
Assert.AreEqual(25, result.Count);
for (int i = 0; i < 25; i++)
{
Assert.AreEqual(i, result[i].Id, $"Id mismatch at index {i}");
Assert.AreEqual($"FirstName_{i % 10}", result[i].FirstName, $"FirstName mismatch at index {i}");
Assert.AreEqual($"LastName_{i % 8}", result[i].LastName, $"LastName mismatch at index {i}");
Assert.AreEqual($"user{i}@example.com", result[i].Email, $"Email mismatch at index {i}");
Assert.AreEqual(4, result[i].Attributes.Count, $"Attributes count mismatch at index {i}");
Assert.AreEqual("DateOfReceipt", result[i].Attributes[0].Key);
Assert.AreEqual("Priority", result[i].Attributes[1].Key);
}
}
[TestMethod]
public void StringInterning_LargeListWithHighStringReuse_HandlesCorrectly()
{
// Large dataset (100+ items) with high string reuse ratio
// This is the scenario that triggers production bugs
const int itemCount = 150;
var items = Enumerable.Range(0, itemCount).Select(i => new TestHighReuseDto
{
Id = i,
// Property names are reused 150 times (once per object)
CategoryCode = $"CAT_{i % 10:D2}", // 10 unique values, 15x reuse each
StatusCode = $"STATUS_{i % 5:D2}", // 5 unique values, 30x reuse each
TypeCode = $"TYPE_{i % 3:D2}", // 3 unique values, 50x reuse each
PriorityCode = i % 2 == 0 ? "PRIORITY_HIGH" : "PRIORITY_LOW", // 2 values, 75x each
UniqueField = $"UNIQUE_{i:D4}" // All unique, no reuse
}).ToList();
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestHighReuseDto>>();
Assert.IsNotNull(result, "Result should not be null");
Assert.AreEqual(itemCount, result.Count, $"Expected {itemCount} items");
// Verify every item
for (int i = 0; i < itemCount; i++)
{
Assert.AreEqual(i, result[i].Id, $"Id mismatch at {i}");
Assert.AreEqual($"CAT_{i % 10:D2}", result[i].CategoryCode, $"CategoryCode mismatch at {i}");
Assert.AreEqual($"STATUS_{i % 5:D2}", result[i].StatusCode, $"StatusCode mismatch at {i}");
Assert.AreEqual($"TYPE_{i % 3:D2}", result[i].TypeCode, $"TypeCode mismatch at {i}");
Assert.AreEqual($"UNIQUE_{i:D4}", result[i].UniqueField, $"UniqueField mismatch at {i}");
}
}
#endregion
#region Test Models
private class TestSimpleClass
{
public int Id { get; set; }
public string Name { get; set; } = "";
public double Value { get; set; }
public bool IsActive { get; set; }
}
private class TestNestedClass
{
public int Id { get; set; }
public string Name { get; set; } = "";
public TestSimpleClass? Child { get; set; }
}
private class TestClassWithList
{
public int Id { get; set; }
public List<string> Items { get; set; } = new();
}
private class TestClassWithRepeatedStrings
{
public string Field1 { get; set; } = "";
public string Field2 { get; set; } = "";
public string Field3 { get; set; } = "";
public string Field4 { get; set; } = "";
}
// New test models for string interning edge cases
private class TestClassWithLongPropertyNames
{
public string FirstProperty { get; set; } = "";
public string SecondProperty { get; set; } = "";
public string ThirdProperty { get; set; } = "";
public string FourthProperty { get; set; } = "";
}
private class TestClassWithMixedStrings
{
public int Id { get; set; }
public string ShortName { get; set; } = ""; // < 4 chars
public string LongName { get; set; } = ""; // >= 4 chars
public string Description { get; set; } = ""; // >= 4 chars
public string Tag { get; set; } = ""; // < 4 chars
}
private class TestNestedStructure
{
public string RootName { get; set; } = "";
public List<TestLevel1> Level1Items { get; set; } = new();
}
private class TestLevel1
{
public string Level1Name { get; set; } = "";
public List<TestLevel2> Level2Items { get; set; } = new();
}
private class TestLevel2
{
public string Level2Name { get; set; } = "";
public string Value { get; set; } = "";
}
private class TestClassWithRepeatedValues
{
public int Id { get; set; }
public string Status { get; set; } = "";
public string Category { get; set; } = "";
public string Priority { get; set; } = "";
}
private class TestClassWithNameValue
{
public string Name { get; set; } = "";
public string Value { get; set; } = "";
}
private class TestClassWithNullableStrings
{
public int Id { get; set; }
public string RequiredName { get; set; } = "";
public string? OptionalName { get; set; }
public string? Description { get; set; }
}
private class TestCustomerLikeDto
{
public int Id { get; set; }
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string Email { get; set; } = "";
public string Company { get; set; } = "";
public string Department { get; set; } = "";
public string Role { get; set; } = "";
public string Status { get; set; } = "";
public List<TestGenericAttribute> Attributes { get; set; } = new();
}
private class TestGenericAttribute
{
public string Key { get; set; } = "";
public string Value { get; set; } = "";
}
private class TestHighReuseDto
{
public int Id { get; set; }
public string CategoryCode { get; set; } = "";
public string StatusCode { get; set; } = "";
public string TypeCode { get; set; } = "";
public string PriorityCode { get; set; } = "";
public string UniqueField { get; set; } = "";
}
#endregion
#region Benchmark Order Tests
[TestMethod]
public void Serialize_BenchmarkOrder_RoundTrip()
{
// This is the exact same data that causes stack overflow in benchmarks
var order = TestDataFactory.CreateBenchmarkOrder(
itemCount: 3,
palletsPerItem: 2,
measurementsPerPallet: 2,
pointsPerMeasurement: 5);
// Should not throw stack overflow
var binary = AcBinarySerializer.Serialize(order);
Assert.IsTrue(binary.Length > 0, "Binary data should not be empty");
var result = AcBinaryDeserializer.Deserialize<TestOrder>(binary);
Assert.IsNotNull(result);
Assert.AreEqual(order.Id, result.Id);
Assert.AreEqual(order.OrderNumber, result.OrderNumber);
Assert.AreEqual(order.Items.Count, result.Items.Count);
}
[TestMethod]
public void Serialize_BenchmarkOrder_SmallData_RoundTrip()
{
// Smaller test to isolate the issue
var order = TestDataFactory.CreateBenchmarkOrder(
itemCount: 1,
palletsPerItem: 1,
measurementsPerPallet: 1,
pointsPerMeasurement: 1);
var binary = AcBinarySerializer.Serialize(order);
Assert.IsTrue(binary.Length > 0);
var result = AcBinaryDeserializer.Deserialize<TestOrder>(binary);
Assert.IsNotNull(result);
Assert.AreEqual(order.Id, result.Id);
}
#endregion
}

View File

@ -0,0 +1,460 @@
using AyCode.Core.Extensions;
using AyCode.Core.Interfaces;
using MessagePack;
using MongoDB.Bson.Serialization.Attributes;
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 - ignored by all serializers to prevent circular references
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
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; }
// Parent reference - ignored by all serializers to prevent circular references
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
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; }
// Parent reference - ignored by all serializers to prevent circular references
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
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; } = [];
// Parent reference - ignored by all serializers to prevent circular references
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
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;
// Parent reference - ignored by all serializers to prevent circular references
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
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>
/// Simulates NopCommerce GenericAttribute - stores key-value pairs where DateTime values
/// are stored as strings in the database.
/// </summary>
public class TestGenericAttribute
{
public int Id { get; set; }
public string Key { get; set; } = "";
public string Value { get; set; } = "";
}
/// <summary>
/// DTO with GenericAttributes collection - simulates OrderDto with string-stored DateTime values.
/// This reproduces the production bug where Binary serialization was thought to corrupt DateTime strings.
/// </summary>
public class TestDtoWithGenericAttributes : IId<int>
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<TestGenericAttribute> GenericAttributes { get; set; } = [];
}
/// <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
#region Property Mismatch DTOs (Server has more properties than Client)
/// <summary>
/// "Server-side" DTO with extra properties that the "client" doesn't know about.
/// Used to test SkipValue functionality when deserializing unknown properties.
/// </summary>
public class ServerCustomerDto : IId<int>
{
public int Id { get; set; }
// Common properties (both server and client have these)
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
// Extra properties (only server has these - client will skip them)
public string Email { get; set; } = "";
public string Phone { get; set; } = "";
public string Address { get; set; } = "";
public string City { get; set; } = "";
public string Country { get; set; } = "";
public string PostalCode { get; set; } = "";
public string Company { get; set; } = "";
public string Department { get; set; } = "";
public string Notes { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
public TestStatus Status { get; set; } = TestStatus.Active;
public bool IsVerified { get; set; }
public int LoginCount { get; set; }
public decimal Balance { get; set; }
}
/// <summary>
/// "Client-side" DTO with fewer properties than the server version.
/// When deserializing ServerCustomerDto data into this type,
/// the deserializer must skip unknown properties correctly
/// while still maintaining string intern table consistency.
/// </summary>
public class ClientCustomerDto : IId<int>
{
public int Id { get; set; }
// Only basic properties - client doesn't need all server fields
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
}
/// <summary>
/// Server DTO with nested objects that client doesn't know about.
/// Tests skipping complex nested structures.
/// </summary>
public class ServerOrderWithExtras : IId<int>
{
public int Id { get; set; }
public string OrderNumber { get; set; } = "";
public decimal TotalAmount { get; set; }
// Nested object that client doesn't have
public ServerCustomerDto? Customer { get; set; }
// List of objects that client doesn't know about
public List<ServerCustomerDto> RelatedCustomers { get; set; } = [];
// Extra simple properties
public string InternalNotes { get; set; } = "";
public string ProcessingCode { get; set; } = "";
}
/// <summary>
/// Client version of the order - doesn't have Customer/RelatedCustomers properties.
/// </summary>
public class ClientOrderSimple : IId<int>
{
public int Id { get; set; }
public string OrderNumber { get; set; } = "";
public decimal TotalAmount { get; set; }
}
#endregion

View File

@ -0,0 +1,268 @@
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>
/// 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, ContractlessOptions);
}
/// <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
# Visual Studio Version 17
VisualStudioVersion = 17.7.34221.43
# Visual Studio Version 18
VisualStudioVersion = 18.0.11222.15
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AyCode.Core", "AyCode.Core\AyCode.Core.csproj", "{8CCC4969-7306-4747-8A58-80AC5A062EE1}"
EndProject
@ -44,6 +44,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
AyCode.Core.targets = AyCode.Core.targets
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AyCode.Benchmark", "AyCode.Benchmark\AyCode.Benchmark.csproj", "{A20861A9-411E-6150-BF5C-69E8196E5D22}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AyCode.Services.Tests", "AyCode.Services.Tests\AyCode.Services.Tests.csproj", "{B8443014-1247-FB9C-7BF4-2CC944075A8B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -149,6 +153,16 @@ Global
{73261A8C-FB41-4C4C-90D4-ED5EEC991413}.Debug|Any CPU.Build.0 = Debug|Any CPU
{73261A8C-FB41-4C4C-90D4-ED5EEC991413}.Product|Any CPU.ActiveCfg = Product|Any CPU
{73261A8C-FB41-4C4C-90D4-ED5EEC991413}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A20861A9-411E-6150-BF5C-69E8196E5D22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A20861A9-411E-6150-BF5C-69E8196E5D22}.Debug|Any CPU.Build.0 = Debug|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}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8443014-1247-FB9C-7BF4-2CC944075A8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8443014-1247-FB9C-7BF4-2CC944075A8B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8443014-1247-FB9C-7BF4-2CC944075A8B}.Product|Any CPU.ActiveCfg = Product|Any CPU
{B8443014-1247-FB9C-7BF4-2CC944075A8B}.Product|Any CPU.Build.0 = Product|Any CPU
{B8443014-1247-FB9C-7BF4-2CC944075A8B}.Release|Any CPU.ActiveCfg = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -12,9 +12,9 @@
<ItemGroup>
<PackageReference Include="AutoMapper" Version="15.0.1" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,192 @@
using System.Runtime.CompilerServices;
namespace AyCode.Core.Extensions;
/// <summary>
/// Options for AcBinarySerializer and AcBinaryDeserializer.
/// Optimized for speed and memory efficiency over raw size.
/// </summary>
public sealed class AcBinarySerializerOptions : AcSerializerOptions
{
public override AcSerializerType SerializerType { get; init; } = AcSerializerType.Binary;
/// <summary>
/// Current binary format version. Incremented when breaking changes are made.
/// </summary>
public const byte FormatVersion = 1;
/// <summary>
/// Default options instance with metadata and string interning enabled.
/// </summary>
public static readonly AcBinarySerializerOptions Default = new();
/// <summary>
/// Options optimized for maximum speed (no metadata, no interning).
/// Use when deserializer knows the exact type structure.
/// </summary>
public static readonly AcBinarySerializerOptions FastMode = new()
{
UseMetadata = false,
UseStringInterning = false,
UseReferenceHandling = false
};
/// <summary>
/// Options for shallow serialization (root level only).
/// </summary>
public static readonly AcBinarySerializerOptions ShallowCopy = new()
{
MaxDepth = 0,
UseReferenceHandling = false
};
/// <summary>
/// Whether to include metadata header with property names.
/// When enabled, property names are stored once and referenced by index.
/// Improves deserialization speed and allows schema evolution.
/// Default: true
/// </summary>
public bool UseMetadata { get; init; } = true;
/// <summary>
/// Whether to intern repeated strings.
/// When enabled, duplicate strings are stored once and referenced by index.
/// Reduces size and memory for objects with many repeated string values.
/// Default: true
/// </summary>
public bool UseStringInterning { get; init; } = true;
/// <summary>
/// Minimum string length to consider for interning.
/// Shorter strings are written inline to avoid overhead.
/// Default: 4 (strings shorter than 4 chars are not interned)
/// </summary>
public byte MinStringInternLength { get; init; } = 4;
/// <summary>
/// Initial capacity for serialization buffer.
/// Default: 4096 bytes
/// </summary>
public int InitialBufferCapacity { get; init; } = 4096;
/// <summary>
/// Creates options with specified max depth.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static AcBinarySerializerOptions WithMaxDepth(byte maxDepth) => new() { MaxDepth = maxDepth };
/// <summary>
/// Creates options without reference handling.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static AcBinarySerializerOptions WithoutReferenceHandling() => new() { UseReferenceHandling = false };
/// <summary>
/// Creates options without metadata (faster but less flexible).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static AcBinarySerializerOptions WithoutMetadata() => new() { UseMetadata = false };
}
/// <summary>
/// Binary type codes for serialization.
/// Designed for fast switch dispatch and compact storage.
/// Lower 5 bits = type code (0-31)
/// Upper 3 bits = flags (interned, reference, has-type-info)
/// </summary>
internal static class BinaryTypeCode
{
// Primitive types (0-15)
public const byte Null = 0;
public const byte True = 1;
public const byte False = 2;
public const byte Int8 = 3;
public const byte UInt8 = 4;
public const byte Int16 = 5;
public const byte UInt16 = 6;
public const byte Int32 = 7;
public const byte UInt32 = 8;
public const byte Int64 = 9;
public const byte UInt64 = 10;
public const byte Float32 = 11;
public const byte Float64 = 12;
public const byte Decimal = 13;
public const byte Char = 14;
// String types (16-19)
public const byte String = 16; // Inline UTF8 string
public const byte StringInterned = 17; // Reference to interned string by index
public const byte StringEmpty = 18; // Empty string marker
// Date/Time types (20-23)
public const byte DateTime = 20;
public const byte DateTimeOffset = 21;
public const byte TimeSpan = 22;
public const byte Guid = 23;
// Enum (24)
public const byte Enum = 24;
// Complex types (25-31)
public const byte Object = 25; // Start of object
public const byte ObjectEnd = 26; // End of object marker
public const byte ObjectRef = 27; // Reference to previously serialized object
public const byte Array = 28; // Start of array/list
public const byte Dictionary = 29; // Start of dictionary
public const byte ByteArray = 30; // Optimized byte[] storage
// Special markers (32+, for header/meta)
// Header flags byte structure (for values >= 64):
// Bit 0 (0x01): HasMetadata
// Bit 1 (0x02): HasReferenceHandling
// Values 32, 33 are legacy for backward compatibility
public const byte MetadataHeader = 32; // Binary has metadata section (legacy, implies HasReferenceHandling=true)
public const byte NoMetadataHeader = 33; // Binary has no metadata (legacy, implies HasReferenceHandling=true)
// New flag-based header markers (48+)
// Base value 48 (0x30 = 00110000) chosen to:
// - Be distinguishable from legacy values (32, 33)
// - Not conflict with flag bits in lower nibble
// - Leave room below Int32Tiny (64)
public const byte HeaderFlagsBase = 48; // Base value for flag-based headers (0x30)
public const byte HeaderFlag_Metadata = 0x01;
public const byte HeaderFlag_ReferenceHandling = 0x02;
// Compact integer variants (for VarInt optimization)
public const byte Int32Tiny = 64; // -16 to 111 stored in single byte (value = code - 64 - 16)
public const byte Int32TinyMax = 191; // Upper bound for tiny int
/// <summary>
/// Check if type code represents a reference (string or object).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsReference(byte code) => code == StringInterned || code == ObjectRef;
/// <summary>
/// Check if type code is a tiny int (single byte int32 encoding).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsTinyInt(byte code) => code >= Int32Tiny && code <= Int32TinyMax;
/// <summary>
/// Decode tiny int value from type code.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int DecodeTinyInt(byte code) => code - Int32Tiny - 16;
/// <summary>
/// Encode small int value (-16 to 111) as type code.
/// Returns true if value fits in tiny encoding.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool TryEncodeTinyInt(int value, out byte code)
{
if (value >= -16 && value <= 111)
{
code = (byte)(value + 16 + Int32Tiny);
return true;
}
code = 0;
return false;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,492 @@
using System.Buffers;
using System.Collections;
using System.Collections.Concurrent;
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using AyCode.Core.Interfaces;
using Newtonsoft.Json;
using static AyCode.Core.Extensions.JsonUtilities;
namespace AyCode.Core.Extensions;
/// <summary>
/// High-performance custom JSON serializer optimized for IId&lt;T&gt; reference handling.
/// Uses Utf8JsonWriter for high-performance UTF-8 output (STJ approach).
/// </summary>
public static class AcJsonSerializer
{
private static readonly ConcurrentDictionary<Type, TypeMetadata> TypeMetadataCache = new();
// Pre-encoded property names for $id/$ref (STJ optimization)
private static readonly JsonEncodedText IdPropertyEncoded = JsonEncodedText.Encode("$id");
private static readonly JsonEncodedText RefPropertyEncoded = JsonEncodedText.Encode("$ref");
/// <summary>
/// Serialize object to JSON string with default options.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string Serialize<T>(T value) => Serialize(value, AcJsonSerializerOptions.Default);
/// <summary>
/// Serialize object to JSON string with specified options.
/// </summary>
public static string Serialize<T>(T value, in AcJsonSerializerOptions options)
{
if (value == null) return "null";
var type = value.GetType();
if (TrySerializePrimitiveRuntime(value, type, out var primitiveJson))
return primitiveJson;
var context = SerializationContextPool.Get(options);
try
{
if (options.UseReferenceHandling)
ScanReferences(value, context, 0);
WriteValue(value, context, 0);
return context.GetResult();
}
finally
{
SerializationContextPool.Return(context);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool TrySerializePrimitiveRuntime(object value, in Type type, out string json)
{
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
var typeCode = Type.GetTypeCode(underlyingType);
switch (typeCode)
{
case TypeCode.String: json = SerializeString((string)value); return true;
case TypeCode.Int32: json = ((int)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.Int64: json = ((long)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.Boolean: json = (bool)value ? "true" : "false"; return true;
case TypeCode.Double:
var d = (double)value;
json = double.IsNaN(d) || double.IsInfinity(d) ? "null" : d.ToString("G17", CultureInfo.InvariantCulture);
return true;
case TypeCode.Decimal: json = ((decimal)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.Single:
var f = (float)value;
json = float.IsNaN(f) || float.IsInfinity(f) ? "null" : f.ToString("G9", CultureInfo.InvariantCulture);
return true;
case TypeCode.DateTime: json = $"\"{((DateTime)value).ToString("O", CultureInfo.InvariantCulture)}\""; return true;
case TypeCode.Byte: json = ((byte)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.Int16: json = ((short)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.UInt16: json = ((ushort)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.UInt32: json = ((uint)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.UInt64: json = ((ulong)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.SByte: json = ((sbyte)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.Char: json = SerializeString(value.ToString()!); return true;
}
if (ReferenceEquals(underlyingType, GuidType)) { json = $"\"{((Guid)value).ToString("D")}\""; return true; }
if (ReferenceEquals(underlyingType, DateTimeOffsetType)) { json = $"\"{((DateTimeOffset)value).ToString("O", CultureInfo.InvariantCulture)}\""; return true; }
if (ReferenceEquals(underlyingType, TimeSpanType)) { json = $"\"{((TimeSpan)value).ToString("c", CultureInfo.InvariantCulture)}\""; return true; }
if (underlyingType.IsEnum) { json = Convert.ToInt32(value).ToString(CultureInfo.InvariantCulture); return true; }
json = "";
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string SerializeString(string value)
{
if (!NeedsEscaping(value)) return string.Concat("\"", value, "\"");
var sb = new StringBuilder(value.Length + 8);
sb.Append('"');
WriteEscapedString(sb, value);
sb.Append('"');
return sb.ToString();
}
#region Reference Scanning
private static void ScanReferences(object? value, SerializationContext context, int depth)
{
if (value == null || depth > context.MaxDepth) return;
var type = value.GetType();
if (IsPrimitiveOrStringFast(type)) return;
if (!context.TrackForScanning(value)) return;
if (value is IEnumerable enumerable && !ReferenceEquals(type, StringType))
{
foreach (var item in enumerable)
if (item != null) ScanReferences(item, context, depth + 1);
return;
}
var metadata = GetTypeMetadata(type);
var props = metadata.Properties;
var propCount = props.Length;
for (var i = 0; i < propCount; i++)
{
var propValue = props[i].GetValue(value);
if (propValue != null) ScanReferences(propValue, context, depth + 1);
}
}
#endregion
#region Serialization
private static void WriteValue(object? value, SerializationContext context, int depth)
{
if (value == null) { context.Writer.WriteNullValue(); return; }
var type = value.GetType();
if (TryWritePrimitive(value, type, context.Writer)) return;
if (depth > context.MaxDepth) { context.Writer.WriteNullValue(); return; }
if (value is IDictionary dictionary) { WriteDictionary(dictionary, context, depth); return; }
if (value is IEnumerable enumerable && !ReferenceEquals(type, StringType)) { WriteArray(enumerable, context, depth); return; }
WriteObject(value, type, context, depth);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WriteObject(object value, in Type type, SerializationContext context, int depth)
{
var writer = context.Writer;
if (context.UseReferenceHandling && context.TryGetExistingRef(value, out var refId))
{
writer.WriteStartObject();
writer.WriteString(RefPropertyEncoded, refId);
writer.WriteEndObject();
return;
}
writer.WriteStartObject();
if (context.UseReferenceHandling && context.ShouldWriteId(value, out var id))
{
writer.WriteString(IdPropertyEncoded, id);
context.MarkAsWritten(value, id);
}
var metadata = GetTypeMetadata(type);
var props = metadata.Properties;
var propCount = props.Length;
var nextDepth = depth + 1;
for (var i = 0; i < propCount; i++)
{
var prop = props[i];
var propValue = prop.GetValue(value);
if (propValue == null) continue;
if (IsDefaultValueFast(propValue, prop.PropertyTypeCode, prop.PropertyType)) continue;
writer.WritePropertyName(prop.JsonNameEncoded);
WriteValue(propValue, context, nextDepth);
}
writer.WriteEndObject();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WriteArray(IEnumerable enumerable, SerializationContext context, int depth)
{
var writer = context.Writer;
writer.WriteStartArray();
var nextDepth = depth + 1;
foreach (var item in enumerable)
{
WriteValue(item, context, nextDepth);
}
writer.WriteEndArray();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WriteDictionary(IDictionary dictionary, SerializationContext context, int depth)
{
var writer = context.Writer;
writer.WriteStartObject();
var nextDepth = depth + 1;
foreach (DictionaryEntry entry in dictionary)
{
writer.WritePropertyName(entry.Key?.ToString() ?? "");
WriteValue(entry.Value, context, nextDepth);
}
writer.WriteEndObject();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool TryWritePrimitive(object value, in Type type, Utf8JsonWriter writer)
{
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
var typeCode = Type.GetTypeCode(underlyingType);
switch (typeCode)
{
case TypeCode.String: writer.WriteStringValue((string)value); return true;
case TypeCode.Int32: writer.WriteNumberValue((int)value); return true;
case TypeCode.Int64: writer.WriteNumberValue((long)value); return true;
case TypeCode.Boolean: writer.WriteBooleanValue((bool)value); return true;
case TypeCode.Double:
var d = (double)value;
if (double.IsNaN(d) || double.IsInfinity(d)) writer.WriteNullValue();
else writer.WriteNumberValue(d);
return true;
case TypeCode.Decimal: writer.WriteNumberValue((decimal)value); return true;
case TypeCode.Single:
var f = (float)value;
if (float.IsNaN(f) || float.IsInfinity(f)) writer.WriteNullValue();
else writer.WriteNumberValue(f);
return true;
case TypeCode.DateTime: writer.WriteStringValue((DateTime)value); return true;
case TypeCode.Byte: writer.WriteNumberValue((byte)value); return true;
case TypeCode.Int16: writer.WriteNumberValue((short)value); return true;
case TypeCode.UInt16: writer.WriteNumberValue((ushort)value); return true;
case TypeCode.UInt32: writer.WriteNumberValue((uint)value); return true;
case TypeCode.UInt64: writer.WriteNumberValue((ulong)value); return true;
case TypeCode.SByte: writer.WriteNumberValue((sbyte)value); return true;
case TypeCode.Char: writer.WriteStringValue(value.ToString()); return true;
}
if (ReferenceEquals(underlyingType, GuidType)) { writer.WriteStringValue((Guid)value); return true; }
if (ReferenceEquals(underlyingType, DateTimeOffsetType)) { writer.WriteStringValue((DateTimeOffset)value); return true; }
if (ReferenceEquals(underlyingType, TimeSpanType)) { writer.WriteStringValue(((TimeSpan)value).ToString("c", CultureInfo.InvariantCulture)); return true; }
if (underlyingType.IsEnum) { writer.WriteNumberValue(Convert.ToInt32(value)); return true; }
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsDefaultValueFast(object value, TypeCode typeCode, in Type propertyType)
{
switch (typeCode)
{
case TypeCode.Int32: return (int)value == 0;
case TypeCode.Int64: return (long)value == 0L;
case TypeCode.Double: return (double)value == 0.0;
case TypeCode.Decimal: return (decimal)value == 0m;
case TypeCode.Single: return (float)value == 0f;
case TypeCode.Byte: return (byte)value == 0;
case TypeCode.Int16: return (short)value == 0;
case TypeCode.UInt16: return (ushort)value == 0;
case TypeCode.UInt32: return (uint)value == 0;
case TypeCode.UInt64: return (ulong)value == 0;
case TypeCode.SByte: return (sbyte)value == 0;
case TypeCode.Boolean: return (bool)value == false;
case TypeCode.String: return string.IsNullOrEmpty((string)value);
}
if (propertyType.IsEnum) return Convert.ToInt32(value) == 0;
if (ReferenceEquals(propertyType, GuidType)) return (Guid)value == Guid.Empty;
return false;
}
#endregion
#region Type Metadata
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static TypeMetadata GetTypeMetadata(in Type type)
=> TypeMetadataCache.GetOrAdd(type, static t => new TypeMetadata(t));
private sealed class TypeMetadata
{
public PropertyAccessor[] Properties { get; }
public TypeMetadata(Type type)
{
Properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead &&
p.GetIndexParameters().Length == 0 &&
!HasJsonIgnoreAttribute(p))
.Select(p => new PropertyAccessor(p))
.ToArray();
}
}
private sealed class PropertyAccessor
{
public readonly string JsonName;
public readonly JsonEncodedText JsonNameEncoded; // STJ optimization - pre-encoded property name
public readonly Type PropertyType;
public readonly TypeCode PropertyTypeCode;
private readonly Func<object, object?> _getter;
public PropertyAccessor(PropertyInfo prop)
{
JsonName = prop.Name;
JsonNameEncoded = JsonEncodedText.Encode(prop.Name);
PropertyType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
PropertyTypeCode = Type.GetTypeCode(PropertyType);
_getter = CreateCompiledGetter(prop.DeclaringType!, prop);
}
private static Func<object, object?> CreateCompiledGetter(Type declaringType, PropertyInfo prop)
{
var objParam = Expression.Parameter(typeof(object), "obj");
var castExpr = Expression.Convert(objParam, declaringType);
var propAccess = Expression.Property(castExpr, prop);
var boxed = Expression.Convert(propAccess, typeof(object));
return Expression.Lambda<Func<object, object?>>(boxed, objParam).Compile();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public object? GetValue(object obj) => _getter(obj);
}
#endregion
#region Context Pool
private static class SerializationContextPool
{
private static readonly ConcurrentQueue<SerializationContext> Pool = new();
private const int MaxPoolSize = 16;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SerializationContext Get(in AcJsonSerializerOptions options)
{
if (Pool.TryDequeue(out var context))
{
context.Reset(options);
return context;
}
return new SerializationContext(options);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Return(SerializationContext context)
{
if (Pool.Count < MaxPoolSize)
{
context.Clear();
Pool.Enqueue(context);
}
}
}
#endregion
#region Serialization Context
private sealed class SerializationContext : IDisposable
{
private readonly ArrayBufferWriter<byte> _buffer;
public Utf8JsonWriter Writer { get; private set; }
private Dictionary<object, int>? _scanOccurrences;
private Dictionary<object, string>? _writtenRefs;
private HashSet<object>? _multiReferenced;
private int _nextId;
public bool UseReferenceHandling { get; private set; }
public byte MaxDepth { get; private set; }
private static readonly JsonWriterOptions WriterOptions = new()
{
Indented = false,
SkipValidation = true // Skip validation for performance
};
public SerializationContext(in AcJsonSerializerOptions options)
{
_buffer = new ArrayBufferWriter<byte>(4096);
Writer = new Utf8JsonWriter(_buffer, WriterOptions);
Reset(options);
}
public void Reset(in AcJsonSerializerOptions options)
{
UseReferenceHandling = options.UseReferenceHandling;
MaxDepth = options.MaxDepth;
_nextId = 1;
if (UseReferenceHandling)
{
_scanOccurrences ??= new Dictionary<object, int>(64, ReferenceEqualityComparer.Instance);
_writtenRefs ??= new Dictionary<object, string>(32, ReferenceEqualityComparer.Instance);
_multiReferenced ??= new HashSet<object>(32, ReferenceEqualityComparer.Instance);
}
}
public void Clear()
{
Writer.Reset();
_buffer.Clear();
_scanOccurrences?.Clear();
_writtenRefs?.Clear();
_multiReferenced?.Clear();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TrackForScanning(object obj)
{
if (_scanOccurrences == null) return true;
ref var count = ref System.Runtime.InteropServices.CollectionsMarshal.GetValueRefOrAddDefault(_scanOccurrences, obj, out var exists);
if (exists) { count++; _multiReferenced!.Add(obj); return false; }
count = 1;
return true;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool ShouldWriteId(object obj, out string id)
{
if (_multiReferenced != null && _multiReferenced.Contains(obj) && !_writtenRefs!.ContainsKey(obj))
{
id = _nextId++.ToString();
return true;
}
id = "";
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void MarkAsWritten(object obj, string id) => _writtenRefs![obj] = id;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetExistingRef(object obj, out string refId)
{
if (_writtenRefs != null) return _writtenRefs.TryGetValue(obj, out refId!);
refId = "";
return false;
}
public string GetResult()
{
Writer.Flush();
return Encoding.UTF8.GetString(_buffer.WrittenSpan);
}
public void Dispose()
{
Writer.Dispose();
}
}
#endregion
}
/// <summary>
/// Reference equality comparer for object identity comparison.
/// </summary>
internal sealed class ReferenceEqualityComparer : IEqualityComparer<object>
{
public static readonly ReferenceEqualityComparer Instance = new();
public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
}

View File

@ -0,0 +1,54 @@
namespace AyCode.Core.Extensions;
public enum AcSerializerType : byte
{
Json = 0,
Binary = 1,
}
public abstract class AcSerializerOptions
{
public abstract AcSerializerType SerializerType { get; init; }
/// <summary>
/// Whether to use $id/$ref reference handling for circular references.
/// Default: true
/// </summary>
public bool UseReferenceHandling { get; init; } = true;
/// <summary>
/// Maximum depth for serialization/deserialization.
/// 0 = root level only (primitives of root object)
/// 1 = root + first level of nested objects/collections
/// byte.MaxValue (255) = effectively unlimited
/// Default: byte.MaxValue
/// </summary>
public byte MaxDepth { get; init; } = byte.MaxValue;
}
/// <summary>
/// Options for AcJsonSerializer and AcJsonDeserializer.
/// </summary>
public sealed class AcJsonSerializerOptions : AcSerializerOptions
{
public override AcSerializerType SerializerType { get; init; } = AcSerializerType.Json;
/// <summary>
/// Default options instance with reference handling enabled and max depth.
/// </summary>
public static readonly AcJsonSerializerOptions Default = new();
/// <summary>
/// Options for shallow serialization (root level only, no references).
/// </summary>
public static readonly AcJsonSerializerOptions ShallowCopy = new() { MaxDepth = 0, UseReferenceHandling = false };
/// <summary>
/// Creates options with specified max depth.
/// </summary>
public static AcJsonSerializerOptions WithMaxDepth(byte maxDepth) => new() { MaxDepth = maxDepth };
/// <summary>
/// Creates options without reference handling.
/// </summary>
public static AcJsonSerializerOptions WithoutReferenceHandling() => new() { UseReferenceHandling = false };
}

View File

@ -0,0 +1,586 @@
using System.Buffers;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Globalization;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using AyCode.Core.Interfaces;
using Newtonsoft.Json;
namespace AyCode.Core.Extensions;
/// <summary>
/// Cached result for IId type info lookup.
/// </summary>
public readonly struct IdTypeInfo
{
public readonly bool IsId;
public readonly Type? IdType;
public IdTypeInfo(bool isId, Type? idType)
{
IsId = isId;
IdType = idType;
}
public void Deconstruct(out bool isId, out Type? idType)
{
isId = IsId;
idType = IdType;
}
}
/// <summary>
/// Central utilities for JSON serialization/deserialization.
/// Contains shared type caches, primitive type checks, and string utilities.
/// </summary>
public static class JsonUtilities
{
#region Pre-computed Type Handles
public static readonly Type IntType = typeof(int);
public static readonly Type LongType = typeof(long);
public static readonly Type DoubleType = typeof(double);
public static readonly Type DecimalType = typeof(decimal);
public static readonly Type FloatType = typeof(float);
public static readonly Type StringType = typeof(string);
public static readonly Type DateTimeType = typeof(DateTime);
public static readonly Type GuidType = typeof(Guid);
public static readonly Type BoolType = typeof(bool);
public static readonly Type DateTimeOffsetType = typeof(DateTimeOffset);
public static readonly Type TimeSpanType = typeof(TimeSpan);
public static readonly Type ByteType = typeof(byte);
public static readonly Type ShortType = typeof(short);
public static readonly Type UShortType = typeof(ushort);
public static readonly Type UIntType = typeof(uint);
public static readonly Type ULongType = typeof(ulong);
public static readonly Type SByteType = typeof(sbyte);
public static readonly Type CharType = typeof(char);
#endregion
#region Cached Generic Type Definitions
internal static readonly Type IEnumerableGenericType = typeof(IEnumerable<>);
internal static readonly Type IIdGenericType = typeof(IId<>);
internal static readonly Type NullableGenericType = typeof(Nullable<>);
internal static readonly Type IListGenericType = typeof(IList<>);
internal static readonly Type ListGenericType = typeof(List<>);
internal static readonly Type DictionaryGenericType = typeof(Dictionary<,>);
internal static readonly Type IDictionaryGenericType = typeof(IDictionary<,>);
internal static readonly Type ObservableCollectionType = typeof(System.Collections.ObjectModel.ObservableCollection<>);
internal static readonly Type CollectionType = typeof(System.Collections.ObjectModel.Collection<>);
#endregion
#region Primitive Type Set
private static readonly FrozenSet<Type> PrimitiveTypes = new HashSet<Type>
{
typeof(string), typeof(decimal), typeof(DateTime),
typeof(DateTimeOffset), typeof(Guid), typeof(TimeSpan),
typeof(bool), typeof(byte), typeof(sbyte), typeof(short),
typeof(ushort), typeof(int), typeof(uint), typeof(long),
typeof(ulong), typeof(float), typeof(double), typeof(char)
}.ToFrozenSet();
#endregion
#region Type Caches
private static readonly ConcurrentDictionary<Type, IdTypeInfo> IdInfoCache = new();
private static readonly ConcurrentDictionary<Type, Type?> CollectionElementCache = new();
private static readonly ConcurrentDictionary<Type, bool> IsPrimitiveCache = new();
private static readonly ConcurrentDictionary<Type, bool> IsCollectionCache = new();
private static readonly ConcurrentDictionary<Type, bool> IsPrimitiveCollectionCache = new();
private static readonly ConcurrentDictionary<PropertyInfo, bool> JsonIgnoreCache = new();
private static readonly ConcurrentDictionary<Type, Func<IList>> ListFactoryCache = new();
#endregion
#region UTF8 Buffer Pool
/// <summary>
/// Rents a UTF8 byte buffer from the shared pool.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte[] RentUtf8Buffer(int minLength)
=> ArrayPool<byte>.Shared.Rent(minLength);
/// <summary>
/// Returns a UTF8 byte buffer to the shared pool.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void ReturnUtf8Buffer(byte[] buffer)
=> ArrayPool<byte>.Shared.Return(buffer);
/// <summary>
/// Converts a JSON string to UTF8 bytes using pooled buffer.
/// Returns the actual byte count written.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static (byte[] buffer, int length) GetUtf8Bytes(string json)
{
var maxByteCount = Encoding.UTF8.GetMaxByteCount(json.Length);
var buffer = RentUtf8Buffer(maxByteCount);
var actualLength = Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0);
return (buffer, actualLength);
}
#endregion
#region String Utilities
/// <summary>
/// Unwraps a JSON string that may be double-quoted (e.g., "\"...\"" -> ...).
/// Optimized to avoid Regex.Unescape allocation when no escape sequences exist.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string UnwrapJsonString(string json)
{
if (string.IsNullOrEmpty(json)) return json;
var span = json.AsSpan();
if (span.Length < 2 || span[0] != '"' || span[^1] != '"')
return json;
var inner = span[1..^1];
if (!inner.Contains('\\'))
return json.Substring(1, json.Length - 2);
return UnescapeJsonString(inner);
}
private static string UnescapeJsonString(ReadOnlySpan<char> input)
{
var sb = new StringBuilder(input.Length);
for (var i = 0; i < input.Length; i++)
{
var c = input[i];
if (c != '\\' || i + 1 >= input.Length)
{
sb.Append(c);
continue;
}
var next = input[i + 1];
switch (next)
{
case '"': sb.Append('"'); i++; break;
case '\\': sb.Append('\\'); i++; break;
case '/': sb.Append('/'); i++; break;
case 'b': sb.Append('\b'); i++; break;
case 'f': sb.Append('\f'); i++; break;
case 'n': sb.Append('\n'); i++; break;
case 'r': sb.Append('\r'); i++; break;
case 't': sb.Append('\t'); i++; break;
case 'u' when i + 5 < input.Length:
var hex = input.Slice(i + 2, 4);
if (TryParseHex(hex, out var unicode))
{
sb.Append((char)unicode);
i += 5;
}
else sb.Append(c);
break;
default: sb.Append(c); break;
}
}
return sb.ToString();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool TryParseHex(ReadOnlySpan<char> hex, out int value)
{
value = 0;
foreach (var c in hex)
{
value <<= 4;
if (c >= '0' && c <= '9') value |= c - '0';
else if (c >= 'a' && c <= 'f') value |= c - 'a' + 10;
else if (c >= 'A' && c <= 'F') value |= c - 'A' + 10;
else return false;
}
return true;
}
/// <summary>
/// Checks if a string needs JSON escaping.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool NeedsEscaping(string value)
{
foreach (var c in value)
{
if (c < 32 || c == '"' || c == '\\')
return true;
}
return false;
}
/// <summary>
/// Checks if a span needs JSON escaping.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool NeedsEscaping(ReadOnlySpan<char> value)
{
foreach (var c in value)
{
if (c < 32 || c == '"' || c == '\\')
return true;
}
return false;
}
/// <summary>
/// Escapes a string for JSON output.
/// </summary>
public static void WriteEscapedString(StringBuilder sb, string value)
{
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;
}
}
}
/// <summary>
/// Escapes a span for JSON output.
/// </summary>
public static void WriteEscapedString(StringBuilder sb, ReadOnlySpan<char> value)
{
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;
}
}
}
#endregion
#region Type Checking Methods
/// <summary>
/// Detects the serializer type from the response data.
/// Checks the first byte after MessagePack deserialization to determine if it's JSON or Binary format.
/// </summary>
/// <param name="responseData">The response data (string for JSON, byte[] for Binary)</param>
/// <returns>The detected serializer type</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static AcSerializerType DetectSerializerType(object? responseData)
{
return responseData switch
{
byte[] => AcSerializerType.Binary,
string => AcSerializerType.Json,
null => AcSerializerType.Json, // Default to JSON for null
_ => AcSerializerType.Json // Default to JSON for unknown types
};
}
/// <summary>
/// Detects if byte array contains JSON or Binary serialized data.
/// JSON typically starts with '{', '[', '"' or whitespace.
/// Binary format starts with version byte (typically 1) followed by metadata flag.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static AcSerializerType DetectSerializerTypeFromBytes(ReadOnlySpan<byte> data)
{
if (data.IsEmpty) return AcSerializerType.Json;
var firstByte = data[0];
// JSON typically starts with: '{' (123), '[' (91), '"' (34), or whitespace (32, 9, 10, 13)
// Also numbers: '0'-'9' (48-57), '-' (45), 't' (116 for true), 'f' (102 for false), 'n' (110 for null)
return firstByte switch
{
(byte)'{' or (byte)'[' or (byte)'"' => AcSerializerType.Json,
(byte)' ' or (byte)'\t' or (byte)'\n' or (byte)'\r' => AcSerializerType.Json,
>= (byte)'0' and <= (byte)'9' => AcSerializerType.Json,
(byte)'-' or (byte)'t' or (byte)'f' or (byte)'n' => AcSerializerType.Json,
// Binary format version 1 with:
// - Legacy metadata header (32) or no-metadata header (33)
// - New flag-based header (34+)
1 when data.Length > 1 && data[1] >= 32 => AcSerializerType.Binary,
_ => AcSerializerType.Binary // Default to Binary for unknown byte patterns
};
}
/// <summary>
/// Detects serializer type from a string (checks if it looks like JSON).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static AcSerializerType DetectSerializerTypeFromString(string? data)
{
if (string.IsNullOrEmpty(data)) return AcSerializerType.Json;
var trimmed = data.AsSpan().Trim();
if (trimmed.IsEmpty) return AcSerializerType.Json;
var firstChar = trimmed[0];
// Valid JSON starts with: '{', '[', '"', number, 't', 'f', 'n'
return firstChar switch
{
'{' or '[' or '"' => AcSerializerType.Json,
>= '0' and <= '9' => AcSerializerType.Json,
'-' or 't' or 'f' or 'n' => AcSerializerType.Json,
_ => AcSerializerType.Binary // Likely Base64 encoded binary
};
}
/// <summary>
/// Fast primitive check using type code.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsPrimitiveOrString(Type type)
{
return IsPrimitiveCache.GetOrAdd(type, static t =>
{
if (t.IsPrimitive || PrimitiveTypes.Contains(t)) return true;
if (t.IsGenericType && t.GetGenericTypeDefinition() == NullableGenericType)
return IsPrimitiveOrString(t.GetGenericArguments()[0]);
if (t.IsEnum) return true;
return false;
});
}
/// <summary>
/// Faster primitive check using TypeCode for hot paths.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsPrimitiveOrStringFast(in 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,
_ => ReferenceEquals(type, GuidType) || ReferenceEquals(type, TimeSpanType) || ReferenceEquals(type, DateTimeOffsetType) || type.IsEnum
};
}
/// <summary>
/// Checks if type is a generic collection type (List, IList, ObservableCollection, etc.)
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsGenericCollectionType(in Type type)
{
return IsCollectionCache.GetOrAdd(type, static t =>
{
if (ReferenceEquals(t, StringType) || t.IsPrimitive) return false;
if (t.IsArray) return true;
if (t.IsGenericType)
{
var genericDef = t.GetGenericTypeDefinition();
if (ReferenceEquals(genericDef, ListGenericType) ||
ReferenceEquals(genericDef, IListGenericType) ||
genericDef == typeof(ICollection<>) ||
ReferenceEquals(genericDef, IEnumerableGenericType) ||
ReferenceEquals(genericDef, ObservableCollectionType) ||
ReferenceEquals(genericDef, CollectionType))
return true;
}
foreach (var iface in t.GetInterfaces())
{
if (iface.IsGenericType && ReferenceEquals(iface.GetGenericTypeDefinition(), IListGenericType))
return true;
}
return typeof(IEnumerable).IsAssignableFrom(t);
});
}
/// <summary>
/// Checks if type is a dictionary type.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsDictionaryType(in Type type, out Type? keyType, out Type? valueType)
{
keyType = null;
valueType = null;
if (!type.IsGenericType) return false;
var genericDef = type.GetGenericTypeDefinition();
if (ReferenceEquals(genericDef, DictionaryGenericType) || ReferenceEquals(genericDef, IDictionaryGenericType))
{
var args = type.GetGenericArguments();
keyType = args[0];
valueType = args[1];
return true;
}
foreach (var iface in type.GetInterfaces())
{
if (iface.IsGenericType && ReferenceEquals(iface.GetGenericTypeDefinition(), IDictionaryGenericType))
{
var args = iface.GetGenericArguments();
keyType = args[0];
valueType = args[1];
return true;
}
}
return false;
}
/// <summary>
/// Gets the element type of a collection.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Type? GetCollectionElementType(in Type collectionType)
{
return CollectionElementCache.GetOrAdd(collectionType, static type =>
{
if (type.IsArray)
return type.GetElementType();
if (type.IsGenericType)
{
var genericDef = type.GetGenericTypeDefinition();
if (ReferenceEquals(genericDef, ListGenericType) || ReferenceEquals(genericDef, IListGenericType) ||
genericDef == typeof(ICollection<>) || ReferenceEquals(genericDef, IEnumerableGenericType))
return type.GetGenericArguments()[0];
}
foreach (var iface in type.GetInterfaces())
{
if (iface.IsGenericType && ReferenceEquals(iface.GetGenericTypeDefinition(), IListGenericType))
return iface.GetGenericArguments()[0];
}
return typeof(object);
});
}
/// <summary>
/// Gets IId info for a type. Returns struct to avoid allocation.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static IdTypeInfo GetIdInfo(in Type type)
{
return IdInfoCache.GetOrAdd(type, static t =>
{
foreach (var iface in t.GetInterfaces())
{
if (!iface.IsGenericType) continue;
if (!ReferenceEquals(iface.GetGenericTypeDefinition(), IIdGenericType)) continue;
var idType = iface.GetGenericArguments()[0];
return new IdTypeInfo(idType.IsValueType, idType);
}
return new IdTypeInfo(false, null);
});
}
/// <summary>
/// Checks if property has JsonIgnore attribute.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool HasJsonIgnoreAttribute(PropertyInfo prop)
{
return JsonIgnoreCache.GetOrAdd(prop, static p =>
Attribute.IsDefined(p, typeof(JsonIgnoreAttribute)) ||
Attribute.IsDefined(p, typeof(System.Text.Json.Serialization.JsonIgnoreAttribute)));
}
/// <summary>
/// Checks if collection contains primitive elements.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsPrimitiveElementCollection(in Type type)
{
return IsPrimitiveCollectionCache.GetOrAdd(type, static t =>
{
if (ReferenceEquals(t, StringType)) return false;
Type? elementType = null;
if (t.IsArray)
elementType = t.GetElementType();
else if (t.IsGenericType && typeof(IEnumerable).IsAssignableFrom(t))
{
var genericArgs = t.GetGenericArguments();
if (genericArgs.Length == 1) elementType = genericArgs[0];
}
if (elementType == null) return false;
return IsPrimitiveOrString(elementType) || elementType.IsEnum;
});
}
/// <summary>
/// Gets or creates a list factory for a given element type.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Func<IList> GetOrCreateListFactory(in Type elementType)
{
return ListFactoryCache.GetOrAdd(elementType, static t =>
{
var listType = ListGenericType.MakeGenericType(t);
var newExpr = System.Linq.Expressions.Expression.New(listType);
var castExpr = System.Linq.Expressions.Expression.Convert(newExpr, typeof(IList));
return System.Linq.Expressions.Expression.Lambda<Func<IList>>(castExpr).Compile();
});
}
/// <summary>
/// Checks if value is the default value for its type.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsDefaultValue(in object id, in Type idType)
{
if (ReferenceEquals(idType, IntType)) return (int)id == 0;
if (ReferenceEquals(idType, LongType)) return (long)id == 0;
if (ReferenceEquals(idType, GuidType)) return (Guid)id == Guid.Empty;
if (ReferenceEquals(idType, ShortType)) return (short)id == 0;
if (ReferenceEquals(idType, ByteType)) return (byte)id == 0;
if (ReferenceEquals(idType, UIntType)) return (uint)id == 0;
if (ReferenceEquals(idType, ULongType)) return (ulong)id == 0;
if (ReferenceEquals(idType, UShortType)) return (ushort)id == 0;
if (ReferenceEquals(idType, SByteType)) return (sbyte)id == 0;
return false;
}
#endregion
}

View File

@ -0,0 +1,459 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Reflection;
using System.Runtime.CompilerServices;
using AyCode.Core.Interfaces;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using static AyCode.Core.Extensions.JsonUtilities;
namespace AyCode.Core.Extensions;
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class JsonNoMergeCollectionAttribute : Attribute { }
/// <summary>
/// Cached property metadata for faster JSON processing.
/// </summary>
public sealed class CachedPropertyInfo
{
public PropertyInfo Property { get; }
public string Name { get; }
public Type PropertyType { get; }
public bool IsIId { get; }
public Type? IdType { get; }
public bool IsIIdCollection { get; }
public Type? CollectionElementType { get; }
public Type? CollectionElementIdType { get; }
public bool ShouldSkip { get; }
public CachedPropertyInfo(PropertyInfo prop)
{
Property = prop;
Name = prop.Name;
PropertyType = prop.PropertyType;
ShouldSkip = !prop.CanRead ||
prop.GetIndexParameters().Length > 0 ||
HasJsonIgnoreAttribute(prop);
if (!ShouldSkip)
{
var idInfo = GetIdInfo(PropertyType);
IsIId = idInfo.IsId;
IdType = idInfo.IdType;
if (!IsIId && typeof(IEnumerable).IsAssignableFrom(PropertyType) && !ReferenceEquals(PropertyType, StringType))
{
CollectionElementType = GetCollectionElementType(PropertyType);
if (CollectionElementType != null)
{
var elemIdInfo = GetIdInfo(CollectionElementType);
IsIIdCollection = elemIdInfo.IsId;
CollectionElementIdType = elemIdInfo.IdType;
}
}
}
}
}
/// <summary>
/// Static type metadata cache for semantic ID generation.
/// </summary>
public static class TypeCache
{
private static readonly ConcurrentDictionary<Type, string> TypeNameCache = new();
private static readonly ConcurrentDictionary<Type, CachedPropertyInfo[]> CachedPropertyInfoCache = new();
private static readonly ConcurrentDictionary<Type, int> TypeIdCache = new();
private static int _typeIdCounter;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int GetTypeId(Type t) => TypeIdCache.GetOrAdd(t, _ => Interlocked.Increment(ref _typeIdCounter));
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static long CreateSemanticId(int typeId, long objectId) => ((long)typeId << 48) | (objectId & 0xFFFFFFFFFFFF);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string GetTypeName(Type t) => TypeNameCache.GetOrAdd(t, static type => type.Name);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static CachedPropertyInfo[] GetCachedProperties(Type t)
{
return CachedPropertyInfoCache.GetOrAdd(t, static type =>
{
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
var cached = new CachedPropertyInfo[props.Length];
for (var i = 0; i < props.Length; i++)
cached[i] = new CachedPropertyInfo(props[i]);
return cached;
});
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static long IdToLong(object idValue)
{
return idValue switch
{
int i => i,
long l => l,
Guid g => GuidToLong(g),
short s => s,
byte b => b,
uint ui => ui,
ulong ul => (long)ul,
ushort us => us,
sbyte sb => sb,
_ => throw new NotSupportedException($"ID type '{idValue.GetType().Name}' is not supported for semantic ID generation.")
};
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static long IdToLong<TId>(TId id) where TId : struct
{
if (typeof(TId) == typeof(int)) return Unsafe.As<TId, int>(ref id);
if (typeof(TId) == typeof(long)) return Unsafe.As<TId, long>(ref id);
if (typeof(TId) == typeof(Guid)) return GuidToLong(Unsafe.As<TId, Guid>(ref id));
if (typeof(TId) == typeof(short)) return Unsafe.As<TId, short>(ref id);
if (typeof(TId) == typeof(byte)) return Unsafe.As<TId, byte>(ref id);
if (typeof(TId) == typeof(uint)) return Unsafe.As<TId, uint>(ref id);
if (typeof(TId) == typeof(ulong)) return (long)Unsafe.As<TId, ulong>(ref id);
if (typeof(TId) == typeof(ushort)) return Unsafe.As<TId, ushort>(ref id);
if (typeof(TId) == typeof(sbyte)) return Unsafe.As<TId, sbyte>(ref id);
return IdToLong((object)id);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static long GuidToLong(Guid guid)
{
Span<byte> bytes = stackalloc byte[16];
guid.TryWriteBytes(bytes);
return BitConverter.ToInt64(bytes) ^ BitConverter.ToInt64(bytes.Slice(8));
}
}
/// <summary>
/// Converter for IId collections that supports merging by ID.
/// </summary>
public class IdAwareCollectionMergeConverter<TItem, TId> : JsonConverter
where TItem : class, IId<TId>, new() where TId : struct
{
private static readonly int CachedTypeId = TypeCache.GetTypeId(typeof(TItem));
private static readonly EqualityComparer<TId> IdComparer = EqualityComparer<TId>.Default;
public override bool CanRead => true;
public override bool CanWrite => false;
public override bool CanConvert(Type objectType) =>
typeof(ICollection<TItem>).IsAssignableFrom(objectType) || typeof(IEnumerable<TItem>).IsAssignableFrom(objectType);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static long GetSemanticId(TId id) => TypeCache.CreateSemanticId(CachedTypeId, TypeCache.IdToLong(id));
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null) return existingValue;
if (existingValue is not IList targetList)
{
var jsonArrayFallback = JArray.Load(reader);
return jsonArrayFallback.ToObject(objectType, serializer);
}
var isFixedSize = targetList.IsFixedSize;
var jsonArray = JArray.Load(reader);
var jsonCount = jsonArray.Count;
var existingCount = targetList.Count;
var existingItemsMap = new Dictionary<long, TItem>(existingCount);
for (var index = 0; index < existingCount; index++)
{
if (targetList[index] is TItem item && !IdComparer.Equals(item.Id, default))
existingItemsMap[GetSemanticId(item.Id)] = item;
}
var finalItems = new List<TItem>(jsonCount + existingCount);
var processedIds = new HashSet<long>(jsonCount);
for (var i = 0; i < jsonCount; i++)
{
var itemToken = jsonArray[i];
TItem? itemResult = null;
if (itemToken is JObject jObj)
{
var incomingId = IdExtractor.GetIdFromJToken<TId>(jObj);
var hasId = !IdComparer.Equals(incomingId, default);
var semanticId = hasId ? GetSemanticId(incomingId) : 0L;
TItem? existingItem = hasId && existingItemsMap.TryGetValue(semanticId, out var found) ? found : null;
if (existingItem != null)
{
using var subReader = jObj.CreateReader();
serializer.Populate(subReader, existingItem);
itemResult = existingItem;
}
else
itemResult = jObj.ToObject<TItem>(serializer);
}
else
itemResult = itemToken.ToObject<TItem>(serializer);
if (itemResult == null) continue;
var currentId = itemResult.Id;
if (!IdComparer.Equals(currentId, default))
{
if (processedIds.Add(GetSemanticId(currentId)))
finalItems.Add(itemResult);
}
else
finalItems.Add(itemResult);
}
foreach (var kvp in existingItemsMap)
{
if (processedIds.Add(kvp.Key))
finalItems.Add(kvp.Value);
}
if (isFixedSize)
{
var resultArray = new TItem[finalItems.Count];
finalItems.CopyTo(resultArray);
return resultArray;
}
targetList.Clear();
if (targetList is List<TItem> typedList)
{
typedList.EnsureCapacity(finalItems.Count);
typedList.AddRange(finalItems);
}
else
{
for (var i = 0; i < finalItems.Count; i++)
targetList.Add(finalItems[i]);
}
return targetList;
}
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) =>
throw new InvalidOperationException("IdAwareCollectionMergeConverter is read-only.");
}
/// <summary>
/// Static class with inlined ID extraction methods.
/// </summary>
public static class IdExtractor
{
private static readonly string IdPropertyName = nameof(IId<int>.Id);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static TId GetIdFromJToken<TId>(JObject obj) where TId : struct
{
var idPropToken = obj.GetValue(IdPropertyName, StringComparison.OrdinalIgnoreCase);
if (idPropToken == null || idPropToken.Type == JTokenType.Null) return default;
if (typeof(TId) == typeof(int))
{
if (idPropToken.Type == JTokenType.Integer)
return Unsafe.As<int, TId>(ref Unsafe.AsRef((int)idPropToken));
return (TId)(object)idPropToken.Value<int>();
}
if (typeof(TId) == typeof(long))
{
if (idPropToken.Type == JTokenType.Integer)
return Unsafe.As<long, TId>(ref Unsafe.AsRef((long)idPropToken));
return (TId)(object)idPropToken.Value<long>();
}
if (typeof(TId) == typeof(Guid))
{
var stringValue = idPropToken.Type == JTokenType.String
? (string?)idPropToken
: idPropToken.Value<string>();
if (string.IsNullOrEmpty(stringValue)) return default;
if (Guid.TryParse(stringValue, out var guidValue))
return Unsafe.As<Guid, TId>(ref guidValue);
return default;
}
try { return idPropToken.Value<TId>(); }
catch { return default; }
}
}
public class UnifiedMergeContractResolver : DefaultContractResolver
{
private static readonly ConcurrentDictionary<(Type, Type), JsonConverter> CollectionConverterCache = new();
private static readonly ConcurrentDictionary<MemberInfo, bool> NoMergeAttributeCache = new();
private static readonly ConcurrentDictionary<MemberInfo, CachedPropertyConfig> PropertyConfigCache = new();
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool HasNoMergeAttribute(MemberInfo member) =>
NoMergeAttributeCache.GetOrAdd(member, static m => m.GetCustomAttribute<JsonNoMergeCollectionAttribute>() != null);
protected override JsonArrayContract CreateArrayContract(Type objectType)
{
var contract = base.CreateArrayContract(objectType);
if (IsPrimitiveElementCollection(objectType))
{
contract.ItemIsReference = false;
contract.IsReference = false;
}
return contract;
}
protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
{
var property = base.CreateProperty(member, memberSerialization);
var t = property.PropertyType;
if (t == null) return property;
var config = GetOrCreatePropertyConfigRef(member, t);
if (config.IsPrimitiveElementCollection)
{
property.ItemIsReference = false;
property.IsReference = false;
return property;
}
if (config.IsCollection && (!config.IsIdCollection || config.IsExcludedFromMerge))
{
property.ObjectCreationHandling = ObjectCreationHandling.Replace;
return property;
}
if (config.IsIdCollection && config.IdType != null && config.ElementType != null && !config.IsPrimitiveElement)
{
property.Converter = CollectionConverterCache.GetOrAdd((config.ElementType, config.IdType), static k =>
(JsonConverter)Activator.CreateInstance(
typeof(IdAwareCollectionMergeConverter<,>).MakeGenericType(k.Item1, k.Item2))!);
property.ObjectCreationHandling = ObjectCreationHandling.Reuse;
return property;
}
return property;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static CachedPropertyConfig GetOrCreatePropertyConfigRef(MemberInfo member, Type propertyType)
=> PropertyConfigCache.GetOrAdd(member, _ => CreatePropertyConfig(member, propertyType));
private static CachedPropertyConfig CreatePropertyConfig(MemberInfo member, Type propertyType)
{
var config = new CachedPropertyConfig
{
IsPrimitiveElementCollection = IsPrimitiveElementCollection(propertyType),
IsExcludedFromMerge = HasNoMergeAttribute(member),
IsCollection = IsGenericCollectionType(propertyType)
};
if (config.IsCollection)
{
config.ElementType = GetCollectionElementType(propertyType);
if (config.ElementType != null)
{
var idInfo = GetIdInfo(config.ElementType);
if (idInfo.IsId && idInfo.IdType != null)
{
config.IsIdCollection = true;
config.IdType = idInfo.IdType;
}
config.IsPrimitiveElement = IsPrimitiveOrString(config.ElementType);
}
}
return config;
}
private sealed class CachedPropertyConfig
{
public bool IsPrimitiveElementCollection { get; set; }
public bool IsExcludedFromMerge { get; set; }
public bool IsCollection { get; set; }
public bool IsIdCollection { get; set; }
public Type? ElementType { get; set; }
public Type? IdType { get; set; }
public bool IsPrimitiveElement { get; set; }
}
}
public static class JsonPopulateExtensions
{
private static readonly ConcurrentDictionary<(Type, Type), JsonConverter> RootConverterCache = new();
private static readonly UnifiedMergeContractResolver SharedContractResolver = new();
private static readonly JsonSerializer CachedMergeSerializer = CreateMergeSerializer();
private static JsonSerializer CreateMergeSerializer()
{
return JsonSerializer.Create(new JsonSerializerSettings
{
ContractResolver = SharedContractResolver,
PreserveReferencesHandling = PreserveReferencesHandling.Objects,
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
NullValueHandling = NullValueHandling.Ignore,
MetadataPropertyHandling = MetadataPropertyHandling.ReadAhead,
});
}
public static void DeepPopulateWithMerge<T>(this T target, string json, JsonSerializerSettings? settings = null) where T : notnull
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(json);
json = UnwrapJsonString(json);
var resolver = new HybridReferenceResolver(isForMerge: true, estimatedObjectCount: 32);
JsonSerializer serializer;
if (settings == null)
serializer = CachedMergeSerializer;
else
{
settings.ContractResolver ??= SharedContractResolver;
serializer = JsonSerializer.Create(new JsonSerializerSettings(settings) { ReferenceResolverProvider = () => resolver });
}
var originalResolver = serializer.ReferenceResolver;
serializer.ReferenceResolver = resolver;
try
{
if (target is IList targetList)
{
var type = target.GetType();
var elemType = GetCollectionElementType(type);
if (elemType != null)
{
var (isId, idType) = GetIdInfo(elemType);
if (isId && idType != null)
{
var converterInstance = RootConverterCache.GetOrAdd((elemType, idType), static k =>
(JsonConverter)Activator.CreateInstance(
typeof(IdAwareCollectionMergeConverter<,>).MakeGenericType(k.Item1, k.Item2))!);
var token = JToken.Parse(json);
using var reader = token.CreateReader();
converterInstance.ReadJson(reader, target.GetType(), target, serializer);
return;
}
}
}
using var stringReader = new StringReader(json);
using var jsonReader = new JsonTextReader(stringReader);
serializer.Populate(jsonReader, target);
}
finally
{
serializer.ReferenceResolver = originalResolver;
}
}
}

View File

@ -1,83 +1,576 @@
using AyCode.Core.Interfaces;
using MessagePack;
using MessagePack.Resolvers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Collections.Concurrent;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Runtime.CompilerServices;
using System.Runtime.Serialization;
using System.Text;
using AyCode.Core.Interfaces;
using MessagePack;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using static AyCode.Core.Extensions.JsonUtilities;
namespace AyCode.Core.Extensions;
/// <summary>
/// High-performance Base62 encoder for compact $id/$ref values.
/// </summary>
internal static class Base62
{
private const string Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string Encode(long value)
{
if (value == 0) return "0";
var isNegative = value < 0;
if (isNegative) value = -value;
Span<char> buffer = stackalloc char[16];
var index = buffer.Length;
while (value > 0)
{
buffer[--index] = Alphabet[(int)(value % 62)];
value /= 62;
}
if (isNegative) buffer[--index] = '-';
return new string(buffer[index..]);
}
}
/// <summary>
/// High-performance hybrid reference resolver using Base62 encoded semantic IDs.
/// </summary>
public class HybridReferenceResolver : IReferenceResolver
{
internal Dictionary<string, object>? _idToObject;
internal Dictionary<object, string>? _objectToId;
internal HashSet<string>? _referencedIds;
private int _nextNumericId = 1;
private static readonly ConcurrentDictionary<Type, Func<object, object?>> IdGetterCache = new();
public bool IsForMerge { get; }
private readonly int _estimatedObjectCount;
public HybridReferenceResolver(bool isForMerge = false, int estimatedObjectCount = 64)
{
IsForMerge = isForMerge;
_estimatedObjectCount = estimatedObjectCount;
}
internal HashSet<string> ReferencedIds => _referencedIds ??=
new HashSet<string>(_estimatedObjectCount / 4, StringComparer.Ordinal);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private Dictionary<string, object> GetIdToObject() =>
_idToObject ??= new Dictionary<string, object>(_estimatedObjectCount, StringComparer.Ordinal);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private Dictionary<object, string> GetObjectToId() =>
_objectToId ??= new Dictionary<object, string>(_estimatedObjectCount, ReferenceEqualityComparer.Instance);
public void AddReference(object context, string reference, object value)
{
GetIdToObject()[reference] = value;
GetObjectToId()[value] = reference;
}
public string GetReference(object context, object value)
{
var objectToId = GetObjectToId();
if (objectToId.TryGetValue(value, out var existingId))
{
if (!IsForMerge) ReferencedIds.Add(existingId);
return existingId;
}
var type = value.GetType();
var (isId, idType) = GetIdInfo(type);
string newRef;
if (isId && idType != null)
{
var idGetter = GetOrCreateIdGetter(type);
var idValue = idGetter(value);
if (idValue != null && !IsDefaultValue(idValue, idType))
{
var typeId = TypeCache.GetTypeId(type);
var objectIdAsLong = TypeCache.IdToLong(idValue);
var semanticId = TypeCache.CreateSemanticId(typeId, objectIdAsLong);
newRef = Base62.Encode(semanticId);
}
else
newRef = Base62.Encode(-_nextNumericId++);
}
else
newRef = Base62.Encode(-_nextNumericId++);
GetIdToObject()[newRef] = value;
objectToId[value] = newRef;
return newRef;
}
public bool IsReferenced(object context, object value) => _objectToId?.ContainsKey(value) ?? false;
public object ResolveReference(object context, string reference) =>
_idToObject != null && _idToObject.TryGetValue(reference, out var value) ? value : null!;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Func<object, object?> GetOrCreateIdGetter(Type type) =>
IdGetterCache.GetOrAdd(type, static t =>
{
var prop = t.GetProperty("Id");
if (prop == null) return static _ => null;
var getMethod = prop.GetGetMethod();
if (getMethod == null) return static _ => null;
return obj => getMethod.Invoke(obj, null);
});
}
internal static class JsonReferencePostProcessor
{
private const string IdMarker = "\"$id\"";
public static string RemoveUnreferencedIds(string json, HashSet<string>? referencedIds)
{
if (!json.Contains(IdMarker)) return json;
return referencedIds == null || referencedIds.Count == 0
? RemoveAllIdsSpan(json)
: RemoveUnreferencedIdsSpan(json, referencedIds);
}
private static string RemoveAllIdsSpan(string json)
{
var sb = new StringBuilder(json.Length);
var lastCopyEnd = 0;
var searchStart = 0;
while (searchStart < json.Length)
{
var idIndex = json.IndexOf(IdMarker, searchStart, StringComparison.Ordinal);
if (idIndex < 0) break;
if (idIndex > lastCopyEnd) sb.Append(json, lastCopyEnd, idIndex - lastCopyEnd);
var endIndex = SkipIdEntry(json, idIndex);
lastCopyEnd = endIndex;
searchStart = endIndex;
}
if (lastCopyEnd < json.Length) sb.Append(json, lastCopyEnd, json.Length - lastCopyEnd);
return sb.Length == json.Length ? json : sb.ToString();
}
private static string RemoveUnreferencedIdsSpan(string json, HashSet<string> referencedIds)
{
var sb = new StringBuilder(json.Length);
var lastCopyEnd = 0;
var searchStart = 0;
while (searchStart < json.Length)
{
var idIndex = json.IndexOf(IdMarker, searchStart, StringComparison.Ordinal);
if (idIndex < 0) break;
var valueStart = idIndex + IdMarker.Length;
while (valueStart < json.Length && (json[valueStart] == ' ' || json[valueStart] == ':'))
valueStart++;
string? idValue = null;
var valueEnd = valueStart;
if (valueStart < json.Length && json[valueStart] == '"')
{
valueStart++;
valueEnd = valueStart;
while (valueEnd < json.Length && json[valueEnd] != '"') valueEnd++;
idValue = json.Substring(valueStart, valueEnd - valueStart);
valueEnd++;
}
while (valueEnd < json.Length && (json[valueEnd] == ' ' || json[valueEnd] == ','))
valueEnd++;
if (idValue != null && referencedIds.Contains(idValue))
searchStart = valueEnd;
else
{
if (idIndex > lastCopyEnd) sb.Append(json, lastCopyEnd, idIndex - lastCopyEnd);
lastCopyEnd = valueEnd;
searchStart = valueEnd;
}
}
if (lastCopyEnd == 0) return json;
if (lastCopyEnd < json.Length) sb.Append(json, lastCopyEnd, json.Length - lastCopyEnd);
return sb.ToString();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int SkipIdEntry(string json, int idIndex)
{
var pos = idIndex + IdMarker.Length;
while (pos < json.Length && (json[pos] == ' ' || json[pos] == ':')) pos++;
if (pos < json.Length && json[pos] == '"')
{
pos++;
while (pos < json.Length && json[pos] != '"') pos++;
if (pos < json.Length) pos++;
}
while (pos < json.Length && (json[pos] == ' ' || json[pos] == ',')) pos++;
return pos;
}
public static HashSet<string> CollectReferencedIds(string json)
{
const string refMarker = "\"$ref\"";
var result = new HashSet<string>(StringComparer.Ordinal);
var searchStart = 0;
while (searchStart < json.Length)
{
var refIndex = json.IndexOf(refMarker, searchStart, StringComparison.Ordinal);
if (refIndex < 0) break;
var valueStart = refIndex + refMarker.Length;
while (valueStart < json.Length && (json[valueStart] == ' ' || json[valueStart] == ':'))
valueStart++;
if (valueStart < json.Length && json[valueStart] == '"')
{
valueStart++;
var valueEnd = valueStart;
while (valueEnd < json.Length && json[valueEnd] != '"') valueEnd++;
if (valueEnd > valueStart) result.Add(json.Substring(valueStart, valueEnd - valueStart));
searchStart = valueEnd + 1;
}
else
searchStart = valueStart;
}
return result;
}
}
internal sealed class PooledStringWriter : StringWriter
{
private static readonly ObjectPool<StringBuilder> StringBuilderPool =
new DefaultObjectPool<StringBuilder>(new StringBuilderPooledObjectPolicy { InitialCapacity = 4096, MaximumRetainedCapacity = 4 * 1024 * 1024 });
private readonly StringBuilder _pooledBuilder;
private bool _disposed;
private PooledStringWriter(StringBuilder sb) : base(sb) => _pooledBuilder = sb;
public static PooledStringWriter Rent()
{
var sb = StringBuilderPool.Get();
sb.Clear();
return new PooledStringWriter(sb);
}
protected override void Dispose(bool disposing)
{
if (!_disposed) { _disposed = true; StringBuilderPool.Return(_pooledBuilder); }
base.Dispose(disposing);
}
}
internal interface ObjectPool<T> where T : class { T Get(); void Return(T obj); }
internal sealed class DefaultObjectPool<T> : ObjectPool<T> where T : class
{
[ThreadStatic] private static T? _threadLocalItem;
private readonly ConcurrentQueue<T> _pool = new();
private readonly IPooledObjectPolicy<T> _policy;
private const int MaxPoolSize = 16;
public DefaultObjectPool(IPooledObjectPolicy<T> policy) => _policy = policy;
public T Get()
{
var item = _threadLocalItem;
if (item != null) { _threadLocalItem = null; return item; }
return _pool.TryDequeue(out item) ? item : _policy.Create();
}
public void Return(T obj)
{
if (!_policy.Return(obj)) return;
if (_threadLocalItem == null) { _threadLocalItem = obj; return; }
if (_pool.Count < MaxPoolSize) _pool.Enqueue(obj);
}
}
internal interface IPooledObjectPolicy<T> { T Create(); bool Return(T obj); }
internal sealed class StringBuilderPooledObjectPolicy : IPooledObjectPolicy<StringBuilder>
{
public int InitialCapacity { get; init; } = 256;
public int MaximumRetainedCapacity { get; init; } = 4 * 1024 * 1024;
public StringBuilder Create() => new(InitialCapacity);
public bool Return(StringBuilder obj) { if (obj.Capacity > MaximumRetainedCapacity) return false; obj.Clear(); return true; }
}
public static class SerializeObjectExtensions
{
public static readonly JsonSerializerSettings Options = new()
private static readonly UnifiedMergeContractResolver SharedContractResolver = new();
private static readonly Dictionary<object, object> EmptyContextDict = new();
public static JsonSerializerSettings Options => new()
{
//TypeNameHandling = TypeNameHandling.All,
ContractResolver = SharedContractResolver,
Context = new StreamingContext(StreamingContextStates.All, EmptyContextDict),
PreserveReferencesHandling = PreserveReferencesHandling.Objects,
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
ReferenceResolverProvider = () => new HybridReferenceResolver(),
NullValueHandling = NullValueHandling.Ignore,
////System.Text.Json
//ReferenceHandler.Preserve
//ReferenceHandler.IgnoreCycles
MetadataPropertyHandling = MetadataPropertyHandling.ReadAhead,
Formatting = Formatting.None,
};
public static string ToJson<T>(this T source, JsonSerializerSettings? options = null) => JsonConvert.SerializeObject(source, options ?? Options);
public static string ToJson<T>(this IQueryable<T> source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson => JsonConvert.SerializeObject(source, options ?? Options);
public static string ToJson<T>(this IEnumerable<T> source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson => JsonConvert.SerializeObject(source, options ?? Options);
public static T? JsonTo<T>(this string json, JsonSerializerSettings? options = null)
{
if (json.StartsWith("\"") && json.EndsWith("\"")) json = Regex.Unescape(json).TrimStart('"').TrimEnd('"');
//JsonConvert.PopulateObject(json, existingObject);
return JsonConvert.DeserializeObject<T>(json, options ?? Options);
}
public static object? JsonTo(this string json, Type toType, JsonSerializerSettings? options = null)
{
if (json.StartsWith("\"") && json.EndsWith("\"")) json = Regex.Unescape(json).TrimStart('"').TrimEnd('"');
return JsonConvert.DeserializeObject(json, toType, options ?? Options);
}
public static void JsonTo(this string json, object target, JsonSerializerSettings? options = null)
{
if (json.StartsWith("\"") && json.EndsWith("\"")) json = Regex.Unescape(json).TrimStart('"').TrimEnd('"');
JsonConvert.PopulateObject(json, target, options ?? Options);
}
/// <summary>
/// Using JSON
/// Serialize object to JSON string with default options.
/// </summary>
/// <typeparam name="TDestination"></typeparam>
/// <param name="src"></param>
/// <param name="options"></param>
/// <returns></returns>
[return: NotNullIfNotNull(nameof(src))]
public static TDestination? CloneTo<TDestination>(this object? src, JsonSerializerSettings? options = null) where TDestination : class
public static string ToJson<T>(this T source) => AcJsonSerializer.Serialize(source);
/// <summary>
/// Serialize object to JSON string with specified options.
/// </summary>
public static string ToJson<T>(this T source, AcJsonSerializerOptions options)
=> AcJsonSerializer.Serialize(source, options);
public static string ToJson<T>(this IQueryable<T> source) where T : class, IAcSerializableToJson
=> AcJsonSerializer.Serialize(source);
public static string ToJson<T>(this IQueryable<T> source, AcJsonSerializerOptions options) where T : class, IAcSerializableToJson
=> AcJsonSerializer.Serialize(source, options);
public static string ToJson<T>(this IEnumerable<T> source) where T : class, IAcSerializableToJson
=> AcJsonSerializer.Serialize(source);
public static string ToJson<T>(this IEnumerable<T> source, AcJsonSerializerOptions options) where T : class, IAcSerializableToJson
=> AcJsonSerializer.Serialize(source, options);
/// <summary>
/// Deserialize JSON to object with default options.
/// </summary>
public static T? JsonTo<T>(this string json)
{
json = UnwrapJsonString(json);
return AcJsonDeserializer.Deserialize<T>(json);
}
/// <summary>
/// Deserialize JSON to object with specified options.
/// </summary>
public static T? JsonTo<T>(this string json, AcJsonSerializerOptions options)
{
json = UnwrapJsonString(json);
return AcJsonDeserializer.Deserialize<T>(json, options);
}
/// <summary>
/// Deserialize JSON to specified type with default options.
/// </summary>
public static object? JsonTo(this string json, Type toType)
{
json = UnwrapJsonString(json);
return AcJsonDeserializer.Deserialize(json, toType);
}
/// <summary>
/// Deserialize JSON to specified type with specified options.
/// </summary>
public static object? JsonTo(this string json, Type toType, AcJsonSerializerOptions options)
{
json = UnwrapJsonString(json);
return AcJsonDeserializer.Deserialize(json, toType, options);
}
/// <summary>
/// Populate existing object from JSON with default options.
/// </summary>
public static void JsonTo(this string json, object target)
{
json = UnwrapJsonString(json);
AcJsonDeserializer.Populate(json, target);
}
/// <summary>
/// Populate existing object from JSON with specified options.
/// </summary>
public static void JsonTo(this string json, object target, AcJsonSerializerOptions options)
{
json = UnwrapJsonString(json);
AcJsonDeserializer.Populate(json, target, options);
}
/// <summary>
/// Clone object via JSON serialization with default options.
/// </summary>
public static TDestination? CloneTo<TDestination>(this object? src) where TDestination : class
=> src?.ToJson().JsonTo<TDestination>();
/// <summary>
/// Clone object via JSON serialization with specified options.
/// </summary>
public static TDestination? CloneTo<TDestination>(this object? src, AcJsonSerializerOptions options) where TDestination : class
=> src?.ToJson(options).JsonTo<TDestination>(options);
/// <summary>
/// Using JSON
/// Copy object properties to target via JSON with default options.
/// </summary>
/// <param name="src"></param>
/// <param name="target"></param>
/// <param name="options"></param>
/// <returns></returns>
public static void CopyTo(this object? src, object target, JsonSerializerSettings? options = null) => src?.ToJson(options).JsonTo(target, options);
public static void CopyTo(this object? src, object target)
=> src?.ToJson().JsonTo(target);
//public static string ToJson(this Expression source) => JsonConvert.SerializeObject(source, Options);
/// <summary>
/// Copy object properties to target via JSON with specified options.
/// </summary>
public static void CopyTo(this object? src, object target, AcJsonSerializerOptions options)
=> src?.ToJson(options).JsonTo(target, options);
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, MessagePackSerializerOptions options)
=> MessagePackSerializer.Deserialize<T>(message, options);
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 object ToAny<T>(this T source, AcSerializerOptions options)
{
if (options.SerializerType == AcSerializerType.Json) return ToJson(source, (AcJsonSerializerOptions)options);
return ToBinary(source, (AcBinarySerializerOptions)options);
}
/// <summary>
/// Deserialize data (JSON string or binary byte[]) to object based on options.
/// </summary>
public static T? AnyTo<T>(this object data, AcSerializerOptions options)
{
if (options.SerializerType == AcSerializerType.Json)
return ((string)data).JsonTo<T>((AcJsonSerializerOptions)options);
return ((byte[])data).BinaryTo<T>();
}
/// <summary>
/// Deserialize data to specified type based on options.
/// </summary>
public static object? AnyTo(this object data, Type targetType, AcSerializerOptions options)
{
if (options.SerializerType == AcSerializerType.Json)
return ((string)data).JsonTo(targetType, (AcJsonSerializerOptions)options);
return ((byte[])data).BinaryTo(targetType);
}
/// <summary>
/// Populate existing object from data based on options.
/// </summary>
public static void AnyTo<T>(this object data, T target, AcSerializerOptions options) where T : class
{
if (options.SerializerType == AcSerializerType.Json)
((string)data).JsonTo(target, (AcJsonSerializerOptions)options);
else
((byte[])data).BinaryTo(target);
}
/// <summary>
/// Populate existing object with merge semantics based on options.
/// </summary>
public static void AnyToMerge<T>(this object data, T target, AcSerializerOptions options) where T : class
{
if (options.SerializerType == AcSerializerType.Json)
((string)data).JsonTo(target, (AcJsonSerializerOptions)options); // JSON always merges
else
((byte[])data).BinaryToMerge(target);
}
/// <summary>
/// Clone object via serialization based on options.
/// </summary>
public static T? CloneToAny<T>(this T source, AcSerializerOptions options) where T : class
{
if (options.SerializerType == AcSerializerType.Json)
return source.CloneTo<T>((AcJsonSerializerOptions)options);
return source.BinaryCloneTo();
}
/// <summary>
/// Copy object properties to target via serialization based on options.
/// </summary>
public static void CopyToAny<T>(this T source, T target, AcSerializerOptions options) where T : class
{
if (options.SerializerType == AcSerializerType.Json)
source.CopyTo(target, (AcJsonSerializerOptions)options);
else
source.BinaryCopyTo(target);
}
#region Binary Serialization Extension Methods
/// <summary>
/// Serialize object to binary byte array with default options.
/// Significantly faster than JSON, especially for large data in WASM.
/// </summary>
public static byte[] ToBinary<T>(this T source) => AcBinarySerializer.Serialize(source);
/// <summary>
/// Serialize object to binary byte array with specified options.
/// </summary>
public static byte[] ToBinary<T>(this T source, AcBinarySerializerOptions options)
=> AcBinarySerializer.Serialize(source, options);
/// <summary>
/// Deserialize binary data to object with default options.
/// </summary>
public static T? BinaryTo<T>(this byte[] data)
=> AcBinaryDeserializer.Deserialize<T>(data);
/// <summary>
/// Deserialize binary data to object.
/// </summary>
public static T? BinaryTo<T>(this ReadOnlySpan<byte> data)
=> AcBinaryDeserializer.Deserialize<T>(data);
/// <summary>
/// Deserialize binary data to specified type.
/// </summary>
public static object? BinaryTo(this byte[] data, Type targetType)
=> AcBinaryDeserializer.Deserialize(data.AsSpan(), targetType);
/// <summary>
/// Populate existing object from binary data.
/// </summary>
public static void BinaryTo<T>(this byte[] data, T target) where T : class
=> AcBinaryDeserializer.Populate(data, target);
/// <summary>
/// Populate existing object from binary data with merge semantics for IId collections.
/// </summary>
public static void BinaryToMerge<T>(this byte[] data, T target) where T : class
=> AcBinaryDeserializer.PopulateMerge(data.AsSpan(), target);
/// <summary>
/// Clone object via binary serialization (faster than JSON clone).
/// </summary>
public static T? BinaryCloneTo<T>(this T source) where T : class
=> source?.ToBinary().BinaryTo<T>();
/// <summary>
/// Copy object properties to target via binary serialization.
/// </summary>
public static void BinaryCopyTo<T>(this T source, T target) where T : class
=> source?.ToBinary().BinaryTo(target);
#endregion
}
public class IgnoreAndRenamePropertySerializerContractResolver : DefaultContractResolver
@ -88,55 +581,41 @@ public class IgnoreAndRenamePropertySerializerContractResolver : DefaultContract
public void IgnoreProperty(Type type, params string[] jsonPropertyNames)
{
if (!_ignores.ContainsKey(type)) _ignores[type] = [];
foreach (var prop in jsonPropertyNames) _ignores[type].Add(prop);
if (!_ignores.TryGetValue(type, out var set)) { set = new HashSet<string>(StringComparer.Ordinal); _ignores[type] = set; }
foreach (var prop in jsonPropertyNames) set.Add(prop);
}
public void IncludesProperty(Type type, params string[] jsonPropertyNames)
{
if (!_includes.ContainsKey(type)) _includes[type] = [];
foreach (var prop in jsonPropertyNames) _includes[type].Add(prop);
if (!_includes.TryGetValue(type, out var set)) { set = new HashSet<string>(StringComparer.Ordinal); _includes[type] = set; }
foreach (var prop in jsonPropertyNames) set.Add(prop);
}
public void RenameProperty(Type type, string propertyName, string newJsonPropertyName)
{
if (!_renames.ContainsKey(type)) _renames[type] = new Dictionary<string, string>();
_renames[type][propertyName] = newJsonPropertyName;
if (!_renames.TryGetValue(type, out var dict)) { dict = new Dictionary<string, string>(StringComparer.Ordinal); _renames[type] = dict; }
dict[propertyName] = newJsonPropertyName;
}
protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
{
var property = base.CreateProperty(member, memberSerialization);
if (IsIgnored(property.DeclaringType, property.PropertyName) || !IsIncluded(property.DeclaringType, property.PropertyName))
{
property.ShouldSerialize = i => false;
property.Ignored = true;
}
if (IsRenamed(property.DeclaringType, property.PropertyName, out var newJsonPropertyName))
property.PropertyName = newJsonPropertyName;
{ property.ShouldSerialize = _ => false; property.Ignored = true; }
if (IsRenamed(property.DeclaringType, property.PropertyName, out var newName)) property.PropertyName = newName;
return property;
}
private bool IsIgnored(Type type, string jsonPropertyName)
{
return _ignores.ContainsKey(type) && _ignores[type].Contains(jsonPropertyName);
}
private bool IsIncluded(Type type, string jsonPropertyName)
{
return _includes.Count == 0 || (_includes.ContainsKey(type) && _includes[type].Contains(jsonPropertyName));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool IsIgnored(Type? type, string? name) => type != null && name != null && _ignores.TryGetValue(type, out var set) && set.Contains(name);
private bool IsRenamed(Type type, string jsonPropertyName, out string? newJsonPropertyName)
{
if (_renames.TryGetValue(type, out var renames) && renames.TryGetValue(jsonPropertyName, out newJsonPropertyName)) return true;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool IsIncluded(Type? type, string? name) => _includes.Count == 0 || (type != null && name != null && _includes.TryGetValue(type, out var set) && set.Contains(name));
newJsonPropertyName = null;
return false;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool IsRenamed(Type? type, string? name, out string? newName)
{
if (type != null && name != null && _renames.TryGetValue(type, out var renames) && renames.TryGetValue(name, out newName)) return true;
newName = null; return false;
}
}

View File

@ -2,6 +2,8 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using AyCode.Core.Extensions;
using System.Threading;
namespace AyCode.Core.Helpers
{
@ -11,6 +13,39 @@ namespace AyCode.Core.Helpers
public void Replace(IEnumerable other);
public void RemoveRange(IEnumerable other);
public void Synchronize(NotifyCollectionChangedEventArgs args);
/// <summary>
/// Populates/merges data from object source while suppressing per-item change events.
/// Fires a single Reset event at the end.
/// </summary>
void PopulateFrom(object source);
/// <summary>
/// Populates/merges data from json while suppressing per-item change events.
/// Fires a single Reset event at the end.
/// </summary>
void PopulateFromJson(string json, bool clearAll = false);
/// <summary>
/// Begins a batch update operation. All notifications are suppressed until EndUpdate is called.
/// Supports nested calls - only the outermost EndUpdate triggers the notification.
/// </summary>
public void BeginUpdate();
/// <summary>
/// Ends a batch update operation. Triggers a single Reset notification if this is the outermost call.
/// </summary>
public void EndUpdate();
/// <summary>
/// Forces a Reset notification to refresh bound UI controls.
/// </summary>
public void NotifyReset();
/// <summary>
/// Returns true if currently in a batch update operation.
/// </summary>
public bool IsUpdating { get; }
}
public interface IAcObservableCollection<T> : IAcObservableCollection
@ -20,81 +55,298 @@ namespace AyCode.Core.Helpers
public void SortAndReplace(IEnumerable<T> other, IComparer<T> comparer);
}
/// <summary>
/// Thread-safe ObservableCollection with batch update support.
/// All public methods are synchronized using a lock.
/// </summary>
public class AcObservableCollection<T> : ObservableCollection<T>, IAcObservableCollection<T>
{
private bool _suppressChangedEvent;
private readonly object _syncRoot = new();
private int _updateCount;
/// <summary>
/// Returns true if currently in a batch update operation.
/// </summary>
public bool IsUpdating
{
get
{
lock (_syncRoot)
{
return _updateCount > 0;
}
}
}
/// <summary>
/// Gets the synchronization object for external locking scenarios.
/// </summary>
public object SyncRoot => _syncRoot;
public AcObservableCollection() : base()
{ }
public AcObservableCollection(List<T> list) : base(list)
{ }
public AcObservableCollection(IEnumerable<T> collection) : base(collection)
{ }
public void BeginUpdate()
{
lock (_syncRoot)
{
_updateCount++;
}
}
public void EndUpdate()
{
bool shouldNotify;
lock (_syncRoot)
{
if (_updateCount <= 0) return;
_updateCount--;
shouldNotify = _updateCount == 0;
}
if (shouldNotify) NotifyReset();
}
public void NotifyReset()
{
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
}
public new void Add(T item)
{
lock (_syncRoot)
{
base.Add(item);
}
}
public new bool Remove(T item)
{
lock (_syncRoot)
{
return base.Remove(item);
}
}
public new void Insert(int index, T item)
{
lock (_syncRoot)
{
base.Insert(index, item);
}
}
public new void RemoveAt(int index)
{
lock (_syncRoot)
{
base.RemoveAt(index);
}
}
public new void Clear()
{
lock (_syncRoot)
{
base.Clear();
}
}
public new void Move(int oldIndex, int newIndex)
{
lock (_syncRoot)
{
base.Move(oldIndex, newIndex);
}
}
public new T this[int index]
{
get
{
lock (_syncRoot)
{
return base[index];
}
}
set
{
lock (_syncRoot)
{
base[index] = value;
}
}
}
public new int Count
{
get
{
lock (_syncRoot)
{
return base.Count;
}
}
}
public new bool Contains(T item)
{
lock (_syncRoot)
{
return base.Contains(item);
}
}
public new int IndexOf(T item)
{
lock (_syncRoot)
{
return base.IndexOf(item);
}
}
public new void CopyTo(T[] array, int arrayIndex)
{
lock (_syncRoot)
{
base.CopyTo(array, arrayIndex);
}
}
/// <summary>
/// Returns a snapshot copy of the collection for safe enumeration.
/// </summary>
public List<T> ToList()
{
lock (_syncRoot)
{
return [.. this.Items];
}
}
public void Replace(IEnumerable<T> other)
{
_suppressChangedEvent = true;
Clear();
AddRange(other);
BeginUpdate();
try
{
lock (_syncRoot)
{
base.Clear();
foreach (var item in other) base.Add(item);
}
}
finally
{
EndUpdate();
}
}
public void Replace(IEnumerable other)
{
_suppressChangedEvent = true;
Clear();
foreach (T item in other) Add(item);
_suppressChangedEvent = false;
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
BeginUpdate();
try
{
lock (_syncRoot)
{
base.Clear();
foreach (T item in other) base.Add(item);
}
}
finally
{
EndUpdate();
}
}
public void AddRange(IEnumerable other)
{
_suppressChangedEvent = true;
foreach (var item in other)
BeginUpdate();
try
{
if (item is T tItem) Add(tItem);
lock (_syncRoot)
{
foreach (var item in other)
{
if (item is T tItem) base.Add(tItem);
}
}
}
finally
{
EndUpdate();
}
_suppressChangedEvent = false;
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
}
public void RemoveRange(IEnumerable other)
{
_suppressChangedEvent = true;
foreach (var item in other)
BeginUpdate();
try
{
if (item is T tItem) Remove(tItem);
lock (_syncRoot)
{
foreach (var item in other)
{
if (item is T tItem) base.Remove(tItem);
}
}
}
finally
{
EndUpdate();
}
}
_suppressChangedEvent = false;
public void PopulateFrom(object source)
{
switch (source)
{
case IEnumerable<T> typedSource:
Replace(typedSource);
break;
case IEnumerable enumerable:
Replace(enumerable);
break;
}
}
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
public void PopulateFromJson(string json, bool clearAll = false)
{
BeginUpdate();
try
{
lock (_syncRoot)
{
if (clearAll) base.Clear();
json.JsonTo(this.Items);
}
}
finally
{
EndUpdate();
}
}
public void SortAndReplace(IEnumerable<T> other, IComparer<T> comparer)
{
List<T> values = new(other);
var values = new List<T>(other);
values.Sort(comparer);
Replace(values);
}
public void Sort(IComparer<T> comparer)
{
List<T> values = new(this);
List<T> values;
lock (_syncRoot)
{
values = new List<T>(this.Items);
}
values.Sort(comparer);
Replace(values);
}
@ -122,39 +374,30 @@ namespace AyCode.Core.Helpers
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
{
if (_suppressChangedEvent)
return;
if (IsUpdating) return;
base.OnPropertyChanged(e);
try
{
base.OnPropertyChanged(e);
}
catch (ObjectDisposedException)
{
// A feliratkozott komponens már Disposed - biztonságosan figyelmen kívül hagyjuk
}
}
protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
{
if (_suppressChangedEvent)
return;
if (IsUpdating) return;
base.OnCollectionChanged(e);
try
{
base.OnCollectionChanged(e);
}
catch (ObjectDisposedException)
{
// A feliratkozott komponens már Disposed - biztonságosan figyelmen kívül hagyjuk
}
}
//protected override void ClearItems()
//{
// base.ClearItems();
//}
//protected override void InsertItem(int index, T item)
//{
// base.InsertItem(index, item);
//}
//protected override void MoveItem(int oldIndex, int newIndex)
//{
// base.MoveItem(oldIndex, newIndex);
//}
//public override event NotifyCollectionChangedEventHandler? CollectionChanged
//{
// add => base.CollectionChanged += value;
// remove => base.CollectionChanged -= value;
//}
}
}

View File

@ -5,20 +5,47 @@
public static bool WaitTo(Func<bool> predicate, int msTimeout = 10000, int msDelay = 5, int msFirstDelay = 0)
=> WaitToAsync(predicate, msTimeout, msDelay, msFirstDelay).GetAwaiter().GetResult();
public static bool WaitTo(Func<bool> predicate, int msTimeout, int msDelay, int msFirstDelay, CancellationToken cancellationToken)
=> WaitToAsync(predicate, msTimeout, msDelay, msFirstDelay, cancellationToken).GetAwaiter().GetResult();
public static Task<bool> WaitToAsync(Func<bool> predicate, int msTimeout = 10000, int msDelay = 5, int msFirstDelay = 0)
=> WaitToAsync(predicate, msTimeout, msDelay, msFirstDelay, CancellationToken.None);
public static async Task<bool> WaitToAsync(Func<bool> predicate, int msTimeout, int msDelay, int msFirstDelay, CancellationToken cancellationToken)
{
return ToThreadPoolTask(async () =>
// Use Environment.TickCount64 instead of DateTime.UtcNow.Ticks for better performance
var endTick = Environment.TickCount64 + msTimeout;
if (msFirstDelay > 0)
await Task.Delay(msFirstDelay, cancellationToken).ConfigureAwait(false);
// Check immediately first
if (predicate())
return true;
// Use PeriodicTimer for efficient polling (.NET 6+)
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(msDelay));
while (Environment.TickCount64 < endTick)
{
var result = false;
var dtTimeout = DateTime.UtcNow.AddMilliseconds(msTimeout).Ticks;
cancellationToken.ThrowIfCancellationRequested();
if (predicate())
return true;
if (msFirstDelay > 0) await Task.Delay(msFirstDelay).ConfigureAwait(false);
try
{
if (!await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
break;
}
catch (OperationCanceledException)
{
break;
}
}
while (dtTimeout > DateTime.UtcNow.Ticks && !(result = predicate()))
await Task.Delay(msDelay).ConfigureAwait(false); //Thread.Sleep(msDelay);
return result;
});
// Final check
return predicate();
}
public static void Forget(this Task task)
@ -32,43 +59,36 @@
{
await task.ConfigureAwait(false);
}
catch (Exception ex)
catch
{
await Task.FromException(ex).ConfigureAwait(true);
// Swallow exception - fire and forget semantics
}
}
}
//public static void Forget(this ValueTask task)
//{
// if (!task.IsCompleted || task.IsFaulted)
// _ = ForgetAwaited(task);
public static void Forget(this ValueTask task)
{
if (!task.IsCompleted || task.IsFaulted)
_ = ForgetAwaited(task);
// static async ValueTask ForgetAwaited(ValueTask task)
// {
// try
// {
// await task.ConfigureAwait(false);
// }
// catch (Exception ex)
// {
// //TODO: .net5, .net6 feature! - J.
// ValueTask.FromException(ex).ConfigureAwait(true);
// }
// }
//}
static async Task ForgetAwaited(ValueTask task)
{
try
{
await task.ConfigureAwait(false);
}
catch
{
// Swallow exception - fire and forget semantics
}
}
}
//TODO: Cancellation token params - J.
//public static void RunOnThreadPool(this Task task) => Task.Run(() => _ = task).Forget(); //TODO: Letesztelni, a ThreadId-kat! - J.
public static void RunOnThreadPool(this Action action) => ToThreadPoolTask(action).Forget();
public static void RunOnThreadPool<T>(this Func<T> func) => ToThreadPoolTask(func).Forget();
public static Task ToThreadPoolTask(this Action action) => Task.Run(action);
//public static Task ToThreadPoolTask(this Task task) => Task.Run(() => _ = task);
//public static void ToParallelTaskStart(this Task task) => task.Start();
//public static Task<T> ToThreadPoolTask<T>(this Task<T> task) => Task.Run(() => task);
public static Task<T> ToThreadPoolTask<T>(this Func<Task<T>> func) => Task.Run(func); //TODO: Letesztelni, a ThreadId-kat! - J.
public static Task<T> ToThreadPoolTask<T>(this Func<Task<T>> func) => Task.Run(func);
public static Task<T> ToThreadPoolTask<T>(this Func<T> func) => Task.Run(func);
}
}

View File

@ -1,12 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
namespace AyCode.Core.Interfaces
{
public interface IId<T>
public interface IId<T>// : IEquatable<T>
{
T Id { get; set; }
}

View File

@ -22,11 +22,11 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="MSTest.TestAdapter" Version="4.0.1" />
<PackageReference Include="MSTest.TestFramework" Version="4.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
<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>

View File

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

View File

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

View File

@ -8,12 +8,12 @@
<Import Project="..//AyCode.Core.targets" />
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="MSTest.TestAdapter" Version="4.0.1" />
<PackageReference Include="MSTest.TestFramework" Version="4.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
<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" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -7,12 +7,12 @@
<ItemGroup>
<PackageReference Include="MessagePack.Annotations" Version="3.1.4" />
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.10" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.11" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>

View File

@ -12,12 +12,12 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="MSTest.TestAdapter" Version="4.0.1" />
<PackageReference Include="MSTest.TestFramework" Version="4.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
<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>
@ -33,6 +33,7 @@
<ProjectReference Include="..\AyCode.Models.Server\AyCode.Models.Server.csproj" />
<ProjectReference Include="..\AyCode.Models\AyCode.Models.csproj" />
<ProjectReference Include="..\AyCode.Services.Server\AyCode.Services.Server.csproj" />
<ProjectReference Include="..\AyCode.Services.Tests\AyCode.Services.Tests.csproj" />
<ProjectReference Include="..\AyCode.Services\AyCode.Services.csproj" />
<ProjectReference Include="..\AyCode.Utils.Server\AyCode.Utils.Server.csproj" />
<ProjectReference Include="..\AyCode.Utils\AyCode.Utils.csproj" />

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
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,117 @@
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, MessagePackOptions);
}
/// <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.Message is SignalResponseJsonMessage jsonResponse && jsonResponse.ResponseData != null)
{
return jsonResponse.ResponseData.JsonTo<T>();
}
if (sentMessage.Message is SignalResponseBinaryMessage binaryResponse && binaryResponse.ResponseData != null)
{
return binaryResponse.ResponseData.BinaryTo<T>();
}
return default;
}
/// <summary>
/// Gets the response status from either JSON or Binary message.
/// </summary>
private static SignalResponseStatus? GetResponseStatus(ISignalRMessage message)
{
return message switch
{
SignalResponseJsonMessage jsonMsg => jsonMsg.Status,
SignalResponseBinaryMessage binaryMsg => binaryMsg.Status,
_ => null
};
}
/// <summary>
/// Assert that a response was successful.
/// </summary>
public static void AssertSuccessResponse(SentMessage sentMessage, int expectedTag)
{
var status = GetResponseStatus(sentMessage.Message);
if (status == null)
throw new AssertFailedException($"Response is not a valid SignalR response message. Type: {sentMessage.Message.GetType().Name}");
if (status != SignalResponseStatus.Success)
throw new AssertFailedException($"Expected Success status but got {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 status = GetResponseStatus(sentMessage.Message);
if (status == null)
throw new AssertFailedException($"Response is not a valid SignalR response message. Type: {sentMessage.Message.GetType().Name}");
if (status != SignalResponseStatus.Error)
throw new AssertFailedException($"Expected Error status but got {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,431 @@
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
#region Binary Serialization with GenericAttributes Test
/// <summary>
/// Tests Binary serialization with GenericAttributes containing string-stored DateTime values.
/// This reproduces the production bug scenario where DateTime values stored as strings
/// in GenericAttributes were incorrectly blamed for Binary serialization issues.
/// </summary>
[SignalR(TestSignalRTags.GenericAttributesParam)]
public TestDtoWithGenericAttributes HandleGenericAttributes(TestDtoWithGenericAttributes dto)
{
// Return the same DTO to verify Binary round-trip preserves all values
return dto;
}
/// <summary>
/// Tests Binary serialization with a list of DTOs containing GenericAttributes.
/// This simulates the production scenario with large datasets (e.g., 1834 orders).
/// </summary>
[SignalR(TestSignalRTags.GenericAttributesListParam)]
public List<TestDtoWithGenericAttributes> HandleGenericAttributesList(List<TestDtoWithGenericAttributes> dtos)
{
return dtos;
}
#endregion
#region Large Dataset / List Tests
/// <summary>
/// Tests Binary serialization with a list of TestOrder objects.
/// Used for testing string interning with deeply nested objects.
/// </summary>
[SignalR(TestSignalRTags.TestOrderListParam)]
public List<TestOrder> HandleTestOrderList(List<TestOrder> orders)
{
return orders;
}
#endregion
#region Property Mismatch Tests (Server has more properties than Client)
// Tests for SkipValue string interning bug fix.
// In these tests, the server sends DTOs with more properties than the client knows about.
// The client's deserializer must skip unknown properties while maintaining string intern table consistency.
/// <summary>
/// Handles server DTO and returns the same DTO.
/// Client will deserialize this into ClientCustomerDto (fewer properties).
/// </summary>
[SignalR(TestSignalRTags.PropertyMismatchParam)]
public ServerCustomerDto HandlePropertyMismatch(ServerCustomerDto dto)
{
return dto;
}
/// <summary>
/// Handles list of server DTOs.
/// Client will deserialize into List&lt;ClientCustomerDto&gt;.
/// </summary>
[SignalR(TestSignalRTags.PropertyMismatchListParam)]
public List<ServerCustomerDto> HandlePropertyMismatchList(List<ServerCustomerDto> dtos)
{
return dtos;
}
/// <summary>
/// Handles server order with nested customer objects.
/// Client will deserialize into ClientOrderSimple (no nested objects).
/// </summary>
[SignalR(TestSignalRTags.PropertyMismatchNestedParam)]
public ServerOrderWithExtras HandlePropertyMismatchNested(ServerOrderWithExtras order)
{
return order;
}
/// <summary>
/// Handles list of server orders with nested customer objects.
/// Client will deserialize into List&lt;ClientOrderSimple&gt;.
/// </summary>
[SignalR(TestSignalRTags.PropertyMismatchNestedListParam)]
public List<ServerOrderWithExtras> HandlePropertyMismatchNestedList(List<ServerOrderWithExtras> orders)
{
return orders;
}
#endregion
}

View File

@ -0,0 +1,84 @@
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;
// Binary serialization with GenericAttributes test
public const int GenericAttributesParam = 220;
public const int GenericAttributesListParam = 221;
// Large dataset / List tests
public const int TestOrderListParam = 230;
// Property mismatch tests (Server has more properties than Client)
// Tests SkipValue string interning bug fix
public const int PropertyMismatchParam = 240;
public const int PropertyMismatchListParam = 241;
public const int PropertyMismatchNestedParam = 242;
public const int PropertyMismatchNestedListParam = 243;
}

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,221 @@
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 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>
/// Sets the serializer type for testing (JSON or Binary).
/// </summary>
public void SetSerializerType(AcSerializerType serializerType)
{
SerializerOptions = serializerType == AcSerializerType.Binary
? new AcBinarySerializerOptions()
: new AcJsonSerializerOptions();
}
/// <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,94 @@
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));
}
/// <summary>
/// Sets the serializer type for testing Binary vs JSON serialization.
/// </summary>
public void SetSerializerType(AcSerializerOptions acSerializerOptions)
{
SerializerOptions = acSerializerOptions;
}
#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

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
 <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
</PropertyGroup>
@ -6,11 +6,12 @@
<Import Project="..//AyCode.Core.targets" />
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="9.0.11" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.11" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.11" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="SendGrid" Version="9.29.3" />
</ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,4 @@
using System.Linq.Expressions;
using System.Security.Claims;
using System.Security.Claims;
using AyCode.Core;
using AyCode.Core.Extensions;
using AyCode.Core.Helpers;
@ -17,135 +16,71 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
: Hub<IAcSignalRHubItemServer>, IAcSignalRHubServer where TSignalRTags : AcSignalRTags where TLogger : AcLoggerBase
{
protected readonly List<AcDynamicMethodCallModel<SignalRAttribute>> DynamicMethodCallModels = [];
//protected readonly TIAM.Core.Loggers.Logger<AcWebSignalRHubBase<TSignalRTags>> Logger = new(logWriters.ToArray());
protected TLogger Logger = logger;
protected IConfiguration Configuration = configuration;
protected IConfiguration Configuration = configuration;
//private readonly ServiceProviderAPIController _serviceProviderApiController;
//private readonly TransferDataAPIController _transferDataApiController;
protected AcSerializerOptions SerializerOptions = new AcBinarySerializerOptions();
//_serviceProviderApiController = serviceProviderApiController;
//_transferDataApiController = transferDataApiController;
#region Connection Lifecycle
// https://docs.microsoft.com/en-us/aspnet/core/signalr/hubs?view=aspnetcore-3.1#strongly-typed-hubs
public override async Task OnConnectedAsync()
{
Logger.Debug($"Server OnConnectedAsync; ConnectionId: {Context.ConnectionId}; UserIdentifier: {Context.UserIdentifier}");
Logger.Debug($"Server OnConnectedAsync; ConnectionId: {GetConnectionId()}; UserIdentifier: {GetUserIdentifier()}");
LogContextUserNameAndId();
await base.OnConnectedAsync();
//Clients.Caller.ConnectionId = Context.ConnectionId;
//Clients.Caller.UserIdentifier = Context.UserIdentifier;
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var logText = $"Server OnDisconnectedAsync; ConnectionId: {Context.ConnectionId}; UserIdentifier: {Context.UserIdentifier};";
if (exception == null) Logger.Debug(logText);
else Logger.Error(logText, exception);
var connectionId = GetConnectionId();
var userIdentifier = GetUserIdentifier();
if (exception == null)
Logger.Debug($"Server OnDisconnectedAsync; ConnectionId: {connectionId}; UserIdentifier: {userIdentifier}");
else
Logger.Error($"Server OnDisconnectedAsync; ConnectionId: {connectionId}; UserIdentifier: {userIdentifier}", exception);
LogContextUserNameAndId();
await base.OnDisconnectedAsync(exception);
}
#endregion
#region Message Processing
public virtual Task OnReceiveMessage(int messageTag, byte[]? messageBytes, int? requestId)
{
return ProcessOnReceiveMessage(messageTag, messageBytes, requestId, null);
}
protected async Task ProcessOnReceiveMessage(int messageTag, byte[]? message, int? requestId, Func<string, Task>? notFoundCallback)
protected virtual async Task ProcessOnReceiveMessage(int messageTag, byte[]? message, int? requestId, Func<string, Task>? notFoundCallback)
{
var tagName = ConstHelper.NameByValue<TSignalRTags>(messageTag);
var logText = $"Server OnReceiveMessage; {nameof(requestId)}: {requestId}; ConnectionId: {Context.ConnectionId}; {tagName}";
if (message is { Length: 0 }) Logger.Warning($"message.Length == 0! {logText}");
else Logger.Info($"[{message?.Length:N0}b] {logText}");
if (message is { Length: 0 })
{
Logger.Warning($"message.Length == 0! Server OnReceiveMessage; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}");
}
else
{
Logger.Debug($"[{message?.Length:N0}b] Server OnReceiveMessage; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}");
}
try
{
if (AcDomain.IsDeveloperVersion) LogContextUserNameAndId();
foreach (var methodsByDeclaringObject in DynamicMethodCallModels)
if (TryFindAndInvokeMethod(messageTag, message, tagName, out var responseData))
{
if (!methodsByDeclaringObject.MethodsByMessageTag.TryGetValue(messageTag, out var methodInfoModel)) continue;
object[]? paramValues = null;
logText = $"Found dynamic method for the tag! method: {methodsByDeclaringObject.InstanceObject.GetType().Name}.{methodInfoModel.MethodInfo.Name}";
if (methodInfoModel.ParamInfos is { Length: > 0 })
var responseMessage = CreateResponseMessage(messageTag, SignalResponseStatus.Success, responseData);
if (Logger.LogLevel <= LogLevel.Debug)
{
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)!;
var responseSize = GetResponseSize(responseMessage);
Logger.Debug($"[{responseSize / 1024}kb] responseData serialized ({SerializerOptions.SerializerType})");
}
else Logger.Debug($"{logText}(); {tagName}");
var responseData = methodInfoModel.MethodInfo.InvokeMethod(methodsByDeclaringObject.InstanceObject, paramValues);
var responseDataJson = new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, responseData);
var responseDataJsonKiloBytes = System.Text.Encoding.Unicode.GetByteCount(responseDataJson.ResponseData!) / 1024;
//File.WriteAllText(Path.Combine("h:", $"{requestId}.json"), responseDataJson.ResponseData);
Logger.Info($"[{responseDataJsonKiloBytes}kb] responseData serialized to json");
await ResponseToCaller(messageTag, responseDataJson, requestId);
if (methodInfoModel.Attribute.SendToOtherClientType != SendToClientType.None)
SendMessageToOtherClients(methodInfoModel.Attribute.SendToOtherClientTag, responseData).Forget();
await ResponseToCaller(messageTag, responseMessage, requestId);
return;
}
@ -157,62 +92,213 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
Logger.Error($"Server OnReceiveMessage; {ex.Message}; {tagName}", ex);
}
await ResponseToCaller(messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Error), requestId);
await ResponseToCaller(messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Error, null), requestId);
}
protected Task ResponseToCaller2(int messageTag, object? content)
=> ResponseToCaller(messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null);
/// <summary>
/// Creates a response message using the configured serializer (JSON or Binary).
/// </summary>
protected virtual ISignalRMessage CreateResponseMessage(int messageTag, SignalResponseStatus status, object? responseData)
{
if (SerializerOptions.SerializerType == AcSerializerType.Binary)
{
return new SignalResponseBinaryMessage(messageTag, status, responseData, (AcBinarySerializerOptions)SerializerOptions);
}
return new SignalResponseJsonMessage(messageTag, status, responseData);
}
protected Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId)
/// <summary>
/// Gets the size of the response data for logging purposes.
/// </summary>
private int GetResponseSize(ISignalRMessage responseMessage)
{
return responseMessage switch
{
SignalResponseJsonMessage jsonMsg => System.Text.Encoding.Unicode.GetByteCount(jsonMsg.ResponseData ?? ""),
SignalResponseBinaryMessage binaryMsg => binaryMsg.ResponseData?.Length ?? 0,
_ => 0
};
}
/// <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, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null);
protected virtual Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId)
=> SendMessageToClient(Clients.Caller, messageTag, message, requestId);
protected Task SendMessageToUserId2(string userId, int messageTag, object? content)
=> SendMessageToUserId(userId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null);
protected virtual Task SendMessageToUserIdWithContent(string userId, int messageTag, object? content)
=> SendMessageToUserIdInternal(userId, messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null);
public Task SendMessageToUserId(string userId, int messageTag, ISignalRMessage message, int? requestId)
protected virtual Task SendMessageToUserIdInternal(string userId, int messageTag, ISignalRMessage message, int? requestId)
=> SendMessageToClient(Clients.User(userId), messageTag, message, requestId);
public Task SendMessageToConnectionId2(string connectionId, int messageTag, object? content)
=> SendMessageToConnectionId(connectionId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null);
protected virtual Task SendMessageToConnectionIdWithContent(string connectionId, int messageTag, object? content)
=> SendMessageToConnectionIdInternal(connectionId, messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null);
public Task SendMessageToConnectionId(string connectionId, int messageTag, ISignalRMessage message, int? requestId)
=> SendMessageToClient(Clients.Client(Context.ConnectionId), messageTag, message, requestId);
protected virtual Task SendMessageToConnectionIdInternal(string connectionId, int messageTag, ISignalRMessage message, int? requestId)
=> SendMessageToClient(Clients.Client(connectionId), messageTag, message, requestId);
public Task SendMessageToOtherClients(int messageTag, object? content)
=> SendMessageToClient(Clients.Others, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null);
protected virtual Task SendMessageToOthers(int messageTag, object? content)
=> SendMessageToClient(Clients.Others, messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null);
public Task SendMessageToAllClients(int messageTag, object? content)
=> SendMessageToClient(Clients.All, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null);
protected virtual Task SendMessageToAll(int messageTag, object? content)
=> SendMessageToClient(Clients.All, messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null);
protected async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null)
protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null)
{
var responseDataMessagePack = message.ToMessagePack(ContractlessStandardResolver.Options);
Logger.Info($"[{(responseDataMessagePack.Length/1024)}kb] Server sending responseDataMessagePack to client; {nameof(requestId)}: {requestId}; Aborted: {Context.ConnectionAborted.IsCancellationRequested}; ConnectionId: {Context.ConnectionId}; {ConstHelper.NameByValue<TSignalRTags>(messageTag)}");
var tagName = ConstHelper.NameByValue<TSignalRTags>(messageTag);
Logger.Debug($"[{responseDataMessagePack.Length / 1024}kb] Server sending message to client; requestId: {requestId}; Aborted: {IsConnectionAborted()}; ConnectionId: {GetConnectionId()}; {tagName}");
await sendTo.OnReceiveMessage(messageTag, responseDataMessagePack, requestId);
Logger.Info($"Server sent responseDataMessagePack to client; {nameof(requestId)}: {requestId}; Aborted: {Context.ConnectionAborted.IsCancellationRequested}; ConnectionId: {Context.ConnectionId}; {ConstHelper.NameByValue<TSignalRTags>(messageTag)}");
Logger.Debug($"Server sent message to client; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}");
}
public async Task SendMessageToGroup(string groupId, int messageTag, string message)
{
//await Clients.Group(groupId).Post("", messageTag, message);
}
#endregion
#region Context Accessor Methods (virtual for testing)
/// <summary>
/// Gets the connection ID. Override in tests to avoid Context dependency.
/// </summary>
protected virtual string GetConnectionId() => Context.ConnectionId;
/// <summary>
/// Gets whether the connection is aborted. Override in tests to avoid Context dependency.
/// </summary>
protected virtual bool IsConnectionAborted() => Context.ConnectionAborted.IsCancellationRequested;
/// <summary>
/// Gets the user identifier. Override in tests to avoid Context dependency.
/// </summary>
protected virtual string? GetUserIdentifier() => Context.UserIdentifier;
/// <summary>
/// Gets the ClaimsPrincipal user. Override in tests to avoid Context dependency.
/// </summary>
protected virtual ClaimsPrincipal? GetUser() => Context.User;
#endregion
#region Logging
//[Conditional("DEBUG")]
protected virtual void LogContextUserNameAndId()
{
string? userName = null;
var userId = Guid.Empty;
var user = GetUser();
if (user == null) return;
if (Context.User != null)
{
userName = Context.User.Identity?.Name;
Guid.TryParse(Context.User.FindFirstValue(ClaimTypes.NameIdentifier), out userId);
}
var userName = user.Identity?.Name;
Guid.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier), out var userId);
if (AcDomain.IsDeveloperVersion) Logger.WarningConditional($"SignalR.Context; userName: {userName}; userId: {userId}");
else Logger.Debug($"SignalR.Context; userName: {userName}; userId: {userId}");
if (AcDomain.IsDeveloperVersion)
Logger.WarningConditional($"SignalR.Context; userName: {userName}; userId: {userId}");
else
Logger.Debug($"SignalR.Context; userName: {userName}; userId: {userId}");
}
#endregion
}

View File

@ -5,14 +5,63 @@ namespace AyCode.Services.Server.SignalRs;
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)
{
if (methodInfo.GetCustomAttribute(typeof(AsyncStateMachineAttribute)) is AsyncStateMachineAttribute isAsyncTask)
var result = methodInfo.Invoke(obj, parameters);
if (result == null)
return null;
// Check if result is a Task (this handles both async methods AND Task.FromResult)
if (result is Task task)
{
dynamic awaitable = methodInfo.Invoke(obj, parameters)!;
return awaitable.GetAwaiter().GetResult();
// Wait for task completion
task.GetAwaiter().GetResult();
// Check if it's Task<T> to extract the actual result
var taskType = task.GetType();
if (taskType.IsGenericType)
{
// Get the Result property from Task<T>
var resultProperty = taskType.GetProperty("Result");
if (resultProperty != null)
{
return resultProperty.GetValue(task);
}
}
// Non-generic Task - no result
return null;
}
return methodInfo.Invoke(obj, parameters);
// Handle ValueTask<T>
var type = result.GetType();
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>))
{
// Convert ValueTask<T> to Task<T> and get result
var asTaskMethod = type.GetMethod("AsTask");
if (asTaskMethod != null)
{
var taskResult = (Task)asTaskMethod.Invoke(result, null)!;
taskResult.GetAwaiter().GetResult();
var resultProperty = taskResult.GetType().GetProperty("Result");
return resultProperty?.GetValue(taskResult);
}
}
// Handle non-generic ValueTask
if (result is ValueTask valueTask)
{
valueTask.AsTask().GetAwaiter().GetResult();
return null;
}
// Not a Task - return directly
return result;
}
}

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

@ -6,9 +6,9 @@
<Import Project="..//AyCode.Core.targets" />
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.11" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.11" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />
</ItemGroup>
<ItemGroup>

View File

@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
using static AyCode.Core.Extensions.JsonUtilities;
namespace AyCode.Services.SignalRs
{
@ -16,16 +17,22 @@ namespace AyCode.Services.SignalRs
{
private readonly ConcurrentDictionary<int, SignalRRequestModel> _responseByRequestId = new();
protected readonly HubConnection HubConnection;
protected readonly HubConnection? HubConnection;
protected readonly AcLoggerBase Logger;
//protected event Action<int, byte[], int?> OnMessageReceived = null!;
protected abstract Task MessageReceived(int messageTag, byte[] messageBytes);
public int MsDelay = 25;
public int MsFirstDelay = 50;
public int ConnectionTimeout = 10000;
public int TransportSendTimeout = 60000;
private const string TagsName = "SignalRTags";
/// <summary>
/// Production constructor - creates and starts HubConnection.
/// </summary>
protected AcSignalRClientBase(string fullHubName, AcLoggerBase logger)
{
Logger = logger;
@ -40,6 +47,7 @@ namespace AyCode.Services.SignalRs
options.TransportMaxBufferSize = 30_000_000; //Increasing this value allows the client to receive larger messages. default: 65KB; unlimited: 0;;
options.ApplicationMaxBufferSize = 30_000_000; //Increasing this value allows the client to send larger messages. default: 65KB; unlimited: 0;
options.CloseTimeout = TimeSpan.FromSeconds(10); //default: 5 sec.
options.SkipNegotiation = true; // Skip HTTP negotiation when using WebSockets only
//options.AccessTokenProvider = null;
//options.HttpMessageHandlerFactory = null;
@ -76,7 +84,17 @@ namespace AyCode.Services.SignalRs
_ = HubConnection.On<int, byte[], int?>(nameof(IAcSignalRHubClient.OnReceiveMessage), OnReceiveMessage);
//_ = HubConnection.On<int, int>(nameof(IAcSignalRHubClient.OnRequestMessage), OnRequestMessage);
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)
@ -84,54 +102,147 @@ namespace AyCode.Services.SignalRs
if (_responseByRequestId.IsEmpty) Logger.DebugConditional($"Client HubConnection_Closed");
else Logger.Warning($"Client HubConnection_Closed; {nameof(_responseByRequestId)} count: {_responseByRequestId.Count}");
_responseByRequestId.Clear();
ClearPendingRequests();
return Task.CompletedTask;
}
#region Connection State Methods (virtual for testing)
/// <summary>
/// Gets the current connection state. Override in tests.
/// </summary>
protected virtual HubConnectionState GetConnectionState()
=> HubConnection?.State ?? HubConnectionState.Disconnected;
/// <summary>
/// Checks if the connection is connected. Override in tests.
/// </summary>
protected virtual bool IsConnected()
=> GetConnectionState() == HubConnectionState.Connected;
/// <summary>
/// Starts the connection. Override in tests to avoid real connection.
/// </summary>
protected virtual Task StartConnectionInternal()
{
if (HubConnection == null) return Task.CompletedTask;
return HubConnection.StartAsync();
}
/// <summary>
/// Stops the connection. Override in tests.
/// </summary>
protected virtual Task StopConnectionInternal()
{
if (HubConnection == null) return Task.CompletedTask;
return HubConnection.StopAsync();
}
/// <summary>
/// Disposes the connection. Override in tests.
/// </summary>
protected virtual ValueTask DisposeConnectionInternal()
{
if (HubConnection == null) return ValueTask.CompletedTask;
return HubConnection.DisposeAsync();
}
/// <summary>
/// Sends a message to the server via HubConnection. Override in tests.
/// </summary>
protected virtual Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId)
{
if (HubConnection == null) return Task.CompletedTask;
return HubConnection.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, messageBytes, requestId);
}
#endregion
#region Protected Test Helpers
/// <summary>
/// Gets the pending requests dictionary for testing.
/// </summary>
protected ConcurrentDictionary<int, SignalRRequestModel> GetPendingRequests()
=> _responseByRequestId;
/// <summary>
/// Clears all pending requests.
/// </summary>
protected void ClearPendingRequests()
=> _responseByRequestId.Clear();
/// <summary>
/// Registers a pending request for testing.
/// </summary>
protected void RegisterPendingRequest(int requestId, SignalRRequestModel model)
=> _responseByRequestId[requestId] = model;
/// <summary>
/// Simulates receiving a response for testing.
/// </summary>
protected void SimulateResponse(int requestId, ISignalResponseMessage<string> response)
{
if (_responseByRequestId.TryGetValue(requestId, out var model))
{
model.ResponseByRequestId = response;
model.ResponseDateTime = DateTime.UtcNow;
}
}
#endregion
public async Task StartConnection()
{
if (HubConnection.State == HubConnectionState.Disconnected)
await HubConnection.StartAsync();
if (GetConnectionState() == HubConnectionState.Disconnected)
await StartConnectionInternal();
if (HubConnection.State != HubConnectionState.Connected)
await TaskHelper.WaitToAsync(() => HubConnection.State == HubConnectionState.Connected, ConnectionTimeout, 10, 25);
if (!IsConnected())
await TaskHelper.WaitToAsync(IsConnected, ConnectionTimeout, 10, 25);
}
public async Task StopConnection()
{
await HubConnection.StopAsync();
await HubConnection.DisposeAsync();
await StopConnectionInternal();
await DisposeConnectionInternal();
}
public virtual Task SendMessageToServerAsync(int messageTag)
=> SendMessageToServerAsync(messageTag, null, AcDomain.NextUniqueInt32);
=> SendMessageToServerAsync(messageTag, null, GetNextRequestId());
public virtual Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId)
public virtual async Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId)
{
Logger.DebugConditional($"Client SendMessageToServerAsync sending; {nameof(requestId)}: {requestId}; ConnectionSate: {HubConnection.State}; {ConstHelper.NameByValue(TagsName, messageTag)}");
Logger.DebugConditional($"Client SendMessageToServerAsync sending; {nameof(requestId)}: {requestId}; ConnectionState: {GetConnectionState()}; {ConstHelper.NameByValue(TagsName, messageTag)}");
return StartConnection().ContinueWith(_ =>
await StartConnection();
var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options);
if (!IsConnected())
{
var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options);
Logger.Error($"Client SendMessageToServerAsync error! ConnectionState: {GetConnectionState()};");
return;
}
if (HubConnection.State != HubConnectionState.Connected)
{
Logger.Error($"Client SendMessageToServerAsync error! ConnectionSate: {HubConnection.State};");
return Task.CompletedTask;
}
return HubConnection.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, msgp, requestId);
});
await SendToHubAsync(messageTag, msgp, requestId);
}
#region CRUD
public virtual Task<TResponseData?> PostAsync<TResponseData>(int messageTag, object parameter) //where TResponseData : class
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(parameter)), GetNextRequestId());
public virtual Task<TResponseData?> PostAsync<TResponseData>(int messageTag, object[] parameters) //where TResponseData : class
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(parameters)), GetNextRequestId());
public virtual Task<TResponseData?> GetByIdAsync<TResponseData>(int messageTag, object id) //where TResponseData : class
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(id)), AcDomain.NextUniqueInt32);
=> PostAsync<TResponseData?>(messageTag, id);
public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object id)
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(id)), responseCallback);
public virtual Task<TResponseData?> GetByIdAsync<TResponseData>(int messageTag, object[] ids) //where TResponseData : class
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(ids)), AcDomain.NextUniqueInt32);
=> PostAsync<TResponseData?>(messageTag, ids);
public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object[] ids)
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(ids)), responseCallback);
@ -143,17 +254,49 @@ namespace AyCode.Services.SignalRs
public virtual Task GetAllAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object[]? contextParams)
=> SendMessageToServerAsync(messageTag, (contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams))), responseCallback);
public virtual Task<TResponseData?> GetAllAsync<TResponseData>(int messageTag, object[]? contextParams) //where TResponseData : class
=> SendMessageToServerAsync<TResponseData>(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams)), AcDomain.NextUniqueInt32);
=> SendMessageToServerAsync<TResponseData>(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams)), GetNextRequestId());
public virtual Task<TPostData?> PostDataAsync<TPostData>(int messageTag, TPostData postData) where TPostData : class
=> SendMessageToServerAsync<TPostData>(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), AcDomain.NextUniqueInt32);
=> SendMessageToServerAsync<TPostData>(messageTag, CreatePostMessage(postData), GetNextRequestId());
public virtual Task<TResponseData?> PostDataAsync<TPostData, TResponseData>(int messageTag, TPostData postData) //where TPostData : class where TResponseData : class
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), AcDomain.NextUniqueInt32);
=> SendMessageToServerAsync<TResponseData>(messageTag, CreatePostMessage(postData), GetNextRequestId());
public virtual Task PostDataAsync<TPostData>(int messageTag, TPostData postData, Func<ISignalResponseMessage<TPostData?>, Task> responseCallback) //where TPostData : class
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), responseCallback);
=> SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback);
public virtual Task PostDataAsync<TPostData, TResponseData>(int messageTag, TPostData postData, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback) //where TPostData : class where TResponseData : class
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), responseCallback);
=> SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback);
/// <summary>
/// Creates the appropriate message wrapper for the post data.
/// Primitives, strings, enums, and value types are wrapped in IdMessage.
/// Complex objects are sent directly in SignalPostJsonDataMessage.
/// </summary>
private static ISignalRMessage CreatePostMessage<TPostData>(TPostData postData)
{
var type = typeof(TPostData);
// Primitives, strings, enums, and value types should use IdMessage format
if (IsPrimitiveOrStringOrEnum(type))
{
return new SignalPostJsonDataMessage<IdMessage>(new IdMessage(postData!));
}
// Complex objects use direct serialization
return new SignalPostJsonDataMessage<TPostData>(postData);
}
/// <summary>
/// Determines if a type should use IdMessage format (primitives, strings, enums, value types).
/// Must match the logic in AcWebSignalRHubBase.IsPrimitiveOrStringOrEnum.
/// NOTE: Arrays and collections are NOT included here - they are complex objects for PostDataAsync.
/// </summary>
private static bool IsPrimitiveOrStringOrEnum(Type type)
{
return type == typeof(string) ||
type.IsEnum ||
type.IsValueType ||
type == typeof(DateTime);
}
public Task GetAllIntoAsync<TResponseItem>(List<TResponseItem> intoList, int messageTag, object[]? contextParams = null, Action? callback = null) where TResponseItem : IEntityGuid
{
@ -178,78 +321,120 @@ namespace AyCode.Services.SignalRs
#endregion CRUD
public virtual Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag) //where TResponse : class
=> SendMessageToServerAsync<TResponse>(messageTag, null, AcDomain.NextUniqueInt32);
=> SendMessageToServerAsync<TResponse>(messageTag, null, GetNextRequestId());
public virtual Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag, ISignalRMessage? message) //where TResponse : class
=> SendMessageToServerAsync<TResponse>(messageTag, message, AcDomain.NextUniqueInt32);
=> SendMessageToServerAsync<TResponse>(messageTag, message, GetNextRequestId());
protected virtual async Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag, ISignalRMessage? message, int requestId) //where TResponse : class
{
Logger.DebugConditional($"Client SendMessageToServerAsync<TResult>; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
var startTime = DateTime.Now;
var requestModel = SignalRRequestModelPool.Get();
_responseByRequestId[requestId] = new SignalRRequestModel();
_responseByRequestId[requestId] = requestModel;
await SendMessageToServerAsync(messageTag, message, requestId);
try
{
if (await TaskHelper.WaitToAsync(() => /*HubConnection.State != HubConnectionState.Connected ||*/ _responseByRequestId[requestId].ResponseByRequestId != null, TransportSendTimeout, 25, 50) &&
_responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is ISignalResponseMessage<string> responseMessage)
if (await TaskHelper.WaitToAsync(() => _responseByRequestId[requestId].ResponseByRequestId != null, TransportSendTimeout, MsDelay, MsFirstDelay) &&
_responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is ISignalResponseMessage responseMessage)
{
if (responseMessage.Status == SignalResponseStatus.Error || responseMessage.ResponseData == null)
startTime = obj.RequestDateTime;
SignalRRequestModelPool.Return(obj);
if (responseMessage.Status == SignalResponseStatus.Error)
{
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);
//TODO: Ideiglenes, majd a ResponseMessage-et kell visszaadni a Status miatt! - J.
return await Task.FromException<TResponse>(new Exception(errorText));
//throw new Exception(errorText);
//return default;
}
return responseMessage.ResponseData.JsonTo<TResponse>();
var responseData = DeserializeResponseData<TResponse>(responseMessage);
if (responseData == null && responseMessage.Status == SignalResponseStatus.Success)
{
// Null response is valid for Success status
Logger.Info($"Client received null response. Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
return default;
}
var serializerType = responseMessage switch
{
SignalResponseBinaryMessage => "Binary",
_ => "JSON"
};
Logger.Info($"Client deserialized response ({serializerType}). Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
return responseData;
}
Logger.Error($"Client timeout after: {(DateTime.Now - startTime).TotalSeconds} sec! ConnectionState: {HubConnection?.State}; requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
Logger.Error($"Client timeout after: {(DateTime.Now - startTime).TotalSeconds} sec! ConnectionState: {GetConnectionState()}; requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
}
catch (Exception ex)
{
Logger.Error($"SendMessageToServerAsync; requestId: {requestId}; ConnectionState: {HubConnection?.State}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
Logger.Error($"Client 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;
}
/// <summary>
/// Deserializes response data from either JSON or Binary format.
/// Automatically detects the format based on the response message type.
/// </summary>
private static TResponse? DeserializeResponseData<TResponse>(ISignalResponseMessage responseMessage)
{
return responseMessage switch
{
SignalResponseBinaryMessage binaryMsg when binaryMsg.ResponseData != null
=> binaryMsg.ResponseData.BinaryTo<TResponse>(),
SignalResponseJsonMessage jsonMsg when !string.IsNullOrEmpty(jsonMsg.ResponseData)
=> jsonMsg.ResponseData.JsonTo<TResponse>(),
ISignalResponseMessage<string> stringMsg when !string.IsNullOrEmpty(stringMsg.ResponseData)
=> stringMsg.ResponseData.JsonTo<TResponse>(),
_ => default
};
}
public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
=> SendMessageToServerAsync(messageTag, null, responseCallback);
public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, ISignalRMessage? message, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
{
if (messageTag == 0)
Logger.Error($"SendMessageToServerAsync; messageTag == 0");
if (messageTag == 0) Logger.Error($"SendMessageToServerAsync; messageTag == 0");
var requestId = AcDomain.NextUniqueInt32;
_responseByRequestId[requestId] = new SignalRRequestModel(new Action<ISignalResponseMessage<string>>(responseMessage =>
var requestId = GetNextRequestId();
var requestModel = SignalRRequestModelPool.Get(new Action<ISignalResponseMessage>(responseMessage =>
{
TResponseData? responseData = default;
if (responseMessage.Status == SignalResponseStatus.Success)
{
responseData = string.IsNullOrEmpty(responseMessage.ResponseData) ? default : responseMessage.ResponseData.JsonTo<TResponseData?>();
responseData = DeserializeResponseData<TResponseData>(responseMessage);
}
else Logger.Error($"Client SendMessageToServerAsync<TResponseData> response error; callback; Status: {responseMessage.Status}; ConnectionState: {HubConnection?.State}; requestId: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
else Logger.Error($"Client SendMessageToServerAsync<TResponseData> response error; callback; Status: {responseMessage.Status}; ConnectionState: {GetConnectionState()}; requestId: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
responseCallback(new SignalResponseMessage<TResponseData?>(messageTag, responseMessage.Status, responseData));
}));
_responseByRequestId[requestId] = requestModel;
return SendMessageToServerAsync(messageTag, message, requestId);
}
/// <summary>
/// Gets the next unique request ID.
/// </summary>
protected virtual int GetNextRequestId() => AcDomain.NextUniqueInt32;
public virtual Task OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)
{
var logText = $"Client OnReceiveMessage; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}";
@ -263,9 +448,9 @@ namespace AyCode.Services.SignalRs
var reqId = requestId.Value;
_responseByRequestId[reqId].ResponseDateTime = DateTime.UtcNow;
Logger.Info($"[{_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 = DeserializeResponseMessage(messageBytes);
switch (_responseByRequestId[reqId].ResponseByRequestId)
{
@ -273,56 +458,83 @@ namespace AyCode.Services.SignalRs
_responseByRequestId[reqId].ResponseByRequestId = responseMessage;
return Task.CompletedTask;
case Action<ISignalResponseMessage<string>> messagePackCallback:
_responseByRequestId.TryRemove(reqId, out _);
case Action<ISignalResponseMessage> messageCallback:
if (_responseByRequestId.TryRemove(reqId, out var callbackModel))
{
SignalRRequestModelPool.Return(callbackModel);
}
messagePackCallback.Invoke(responseMessage);
messageCallback.Invoke(responseMessage);
return Task.CompletedTask;
//case Action<string> jsonCallback:
// _responseByRequestId.TryRemove(reqId, out _);
// Legacy support for string-based callbacks
case Action<ISignalResponseMessage<string>> stringCallback when responseMessage is SignalResponseJsonMessage jsonMsg:
if (_responseByRequestId.TryRemove(reqId, out var legacyModel))
{
SignalRRequestModelPool.Return(legacyModel);
}
// jsonCallback.Invoke(responseMessage);
// return Task.CompletedTask;
stringCallback.Invoke(jsonMsg);
return Task.CompletedTask;
default:
Logger.Error($"Client OnReceiveMessage switch; unknown message type: {_responseByRequestId[reqId].ResponseByRequestId?.GetType().Name}; {ConstHelper.NameByValue(TagsName, messageTag)}");
break;
}
_responseByRequestId.TryRemove(reqId, out _);
}
else Logger.Info(logText);
if (_responseByRequestId.TryRemove(reqId, out var removedModel))
{
SignalRRequestModelPool.Return(removedModel);
}
// Request-response hibás eset - ne hívjuk meg a MessageReceived-et
return Task.CompletedTask;
}
// Csak broadcast/notification üzeneteknél hívjuk meg a MessageReceived-et
Logger.Info(logText);
MessageReceived(messageTag, messageBytes).Forget();
}
catch (Exception ex)
{
if (requestId.HasValue)
_responseByRequestId.TryRemove(requestId.Value, out _);
if (requestId.HasValue && _responseByRequestId.TryRemove(requestId.Value, out var exModel))
{
SignalRRequestModelPool.Return(exModel);
}
Logger.Error($"Client OnReceiveMessage; ConnectionState: {HubConnection?.State}; requestId: {requestId}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
Logger.Error($"Client OnReceiveMessage; ConnectionState: {GetConnectionState()}; requestId: {requestId}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
throw;
}
return Task.CompletedTask;
}
//public virtual Task OnRequestMessage(int messageTag, int requestId)
//{
// Logger.DebugConditional($"Client OnRequestMessage; {nameof(messageTag)}: {messageTag}; {nameof(requestId)}: {requestId};");
// try
// {
// OnMessageRequested(messageTag, requestId);
// }
// catch(Exception ex)
// {
// Logger.Error($"Client OnReceiveMessage; {nameof(messageTag)}: {messageTag}; {nameof(requestId)}: {requestId}; {ex.Message}", ex);
// throw;
// }
/// <summary>
/// Deserializes a MessagePack response to the appropriate message type (JSON or Binary).
/// Uses DetectSerializerTypeFromBytes to determine the format of the ResponseData.
/// </summary>
protected virtual ISignalResponseMessage DeserializeResponseMessage(byte[] messageBytes)
{
// First, try to deserialize as Binary message to check the ResponseData format
try
{
var binaryMsg = messageBytes.MessagePackTo<SignalResponseBinaryMessage>(ContractlessStandardResolver.Options);
if (binaryMsg.ResponseData != null && binaryMsg.ResponseData.Length > 0)
{
// Use the existing utility to detect if ResponseData is Binary format
if (DetectSerializerTypeFromBytes(binaryMsg.ResponseData) == AcSerializerType.Binary)
{
return binaryMsg;
}
}
}
catch
{
// Failed to deserialize as Binary message
}
// return Task.CompletedTask;
//}
// Fall back to JSON format
return messageBytes.MessagePackTo<SignalResponseJsonMessage>(ContractlessStandardResolver.Options);
}
}
}

View File

@ -3,4 +3,7 @@
public class AcSignalRTags
{
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 List<string> Ids { get; private set; } = [];
public List<string> Ids { get; private set; }
public IdMessage()
{
Ids = [];
}
public IdMessage(IEnumerable<object> ids) : this()
/// <summary>
/// Creates IdMessage with multiple parameters serialized directly as JSON.
/// Each parameter is serialized independently without array wrapping.
/// Use object[] explicitly to pass multiple parameters.
/// </summary>
public IdMessage(object[] ids)
{
//Ids.AddRange(ids);
Ids.AddRange(ids.Select(x =>
// Pre-allocate capacity to avoid list resizing
Ids = new List<string>(ids.Length);
for (var i = 0; i < ids.Length; i++)
{
string item;
//if (x is Expression expr)
//{
// string aa = string.Empty;
// var serializer = new ExpressionSerializer(new JsonSerializer());
// try
// {
// aa = serializer.SerializeText(expr);
// }
// catch(Exception ex)
// {
// Console.WriteLine(ex);
// }
// item = (new[] { aa }).ToJson();
//}
//else
item = (new[] { x }).ToJson();
return item;
}));
Ids.Add(ids[i].ToJson());
}
}
public IdMessage(object id) : this(new object[] { id })
/// <summary>
/// Creates IdMessage with a single parameter serialized as JSON.
/// Collections (List, Array, etc.) are serialized as a single JSON array.
/// </summary>
public IdMessage(object id)
{
// Pre-allocate for single item
Ids = new List<string>(1) { id.ToJson() };
}
public IdMessage(IEnumerable<Guid> ids) : this(ids.Cast<object>().ToArray())
/// <summary>
/// Creates IdMessage with multiple Guid parameters.
/// Each Guid is serialized as a separate Id entry.
/// </summary>
public IdMessage(IEnumerable<Guid> ids)
{
// Materialize to array once to get count and avoid multiple enumeration
var idsArray = ids as Guid[] ?? ids.ToArray();
Ids = new List<string>(idsArray.Length);
for (var i = 0; i < idsArray.Length; i++)
{
Ids.Add(idsArray[i].ToJson());
}
}
public override string ToString()
@ -59,17 +63,18 @@ public class IdMessage
}
}
[MessagePackObject]
public class SignalPostJsonMessage
{
[Key(0)]
public string PostDataJson { get; set; }
public string PostDataJson { get; set; } = "";
public SignalPostJsonMessage()
{}
protected SignalPostJsonMessage(string postDataJson) => PostDataJson = postDataJson;
}
[MessagePackObject]
[MessagePackObject(AllowPrivate = false)]
public class SignalPostJsonDataMessage<TPostDataType> : SignalPostJsonMessage, ISignalPostMessage<TPostDataType> //where TPostDataType : class
{
[IgnoreMember]
@ -132,6 +137,9 @@ public sealed class SignalResponseJsonMessage : ISignalResponseMessage<string>
[Key(1)] public SignalResponseStatus Status { get; set; }
[Key(2)] public string? ResponseData { get; set; } = null;
[IgnoreMember]
public string? ResponseDataJson => ResponseData;
public SignalResponseJsonMessage(){}
@ -141,42 +149,123 @@ public sealed class SignalResponseJsonMessage : ISignalResponseMessage<string>
MessageTag = messageTag;
}
/// <summary>
/// Creates a response with the given data serialized as JSON.
/// If responseData is already a JSON string (starts with { or [), it will be used directly.
/// All other data types are serialized to JSON format.
/// </summary>
public SignalResponseJsonMessage(int messageTag, SignalResponseStatus status, object? responseData) : this(messageTag, status)
{
if (responseData is string stringdata)
ResponseData = stringdata;
else ResponseData = responseData.ToJson();
}
if (responseData == null)
{
ResponseData = null;
return;
}
public SignalResponseJsonMessage(int messageTag, SignalResponseStatus status, string? responseDataJson) : this(messageTag, status)
// If responseData is already a JSON string, use it directly
if (responseData is string strData)
{
var trimmed = strData.Trim();
if (trimmed.Length > 1 && (trimmed[0] == '{' || trimmed[0] == '[') && (trimmed[^1] == '}' || trimmed[^1] == ']'))
{
// Already JSON - use directly without re-serialization
ResponseData = strData;
return;
}
}
// Serialize to JSON
ResponseData = responseData.ToJson();
}
}
/// <summary>
/// Signal response message with lazy deserialization support.
/// ResponseData is only deserialized on first access and cached.
/// Use ResponseDataJson for direct JSON access without deserialization.
/// </summary>
[MessagePackObject(AllowPrivate = false)]
public sealed class SignalResponseMessage<TResponseData> : ISignalResponseMessage<TResponseData>
{
[IgnoreMember]
private TResponseData? _responseData;
[IgnoreMember]
private bool _isDeserialized;
[Key(0)]
public int MessageTag { get; set; }
[Key(1)]
public SignalResponseStatus Status { get; set; }
/// <summary>
/// Raw JSON string. Use this for direct JSON access without triggering deserialization.
/// </summary>
[Key(2)]
public string? ResponseDataJson { get; set; }
/// <summary>
/// Deserialized response data. Lazy-loaded on first access.
/// </summary>
[IgnoreMember]
public TResponseData? ResponseData
{
ResponseData = responseDataJson;
get
{
if (!_isDeserialized)
{
_isDeserialized = true;
_responseData = ResponseDataJson != null
? ResponseDataJson.JsonTo<TResponseData>()
: default;
}
return _responseData;
}
set
{
_isDeserialized = true;
_responseData = value;
ResponseDataJson = value?.ToJson();
}
}
public SignalResponseMessage()
{
}
public SignalResponseMessage(int messageTag, SignalResponseStatus status)
{
MessageTag = messageTag;
Status = status;
}
public SignalResponseMessage(int messageTag, SignalResponseStatus status, TResponseData? responseData)
: this(messageTag, status)
{
ResponseData = responseData;
}
public SignalResponseMessage(int messageTag, SignalResponseStatus status, string? responseDataJson)
: this(messageTag, status)
{
ResponseDataJson = responseDataJson;
}
}
[MessagePackObject]
public sealed class SignalResponseMessage<TResponseData>(int messageTag, SignalResponseStatus status, TResponseData? responseData) : ISignalResponseMessage<TResponseData>
{
[Key(0)] public int MessageTag { get; set; }
[Key(1)] public SignalResponseStatus Status { get; set; } = status;
[Key(2)] public TResponseData? ResponseData { get; set; } = responseData;
}
public sealed class SignalResponseStatusMessage(SignalResponseStatus status) : ISignalRMessage
{
public SignalResponseStatus Status { get; set; } = status;
}
//[MessagePackObject]
//public sealed class SignalResponseMessage(SignalResponseStatus status) : ISignalResponseMessage
//{
// [Key(0)]
// public SignalResponseStatus Status { get; set; } = status;
//}
public interface ISignalResponseMessage<TResponseData> : ISignalResponseMessage
{
/// <summary>
/// Deserialized response data. May trigger lazy deserialization.
/// </summary>
TResponseData? ResponseData { get; set; }
/// <summary>
/// Raw JSON string for direct access without deserialization.
/// </summary>
string? ResponseDataJson { get; }
}
public interface ISignalResponseMessage : ISignalRMessage
@ -191,6 +280,53 @@ public enum SignalResponseStatus : byte
Success = 5
}
/// <summary>
/// Signal response message with binary serialized data.
/// Used when SerializerOptions.SerializerType == Binary for better performance.
/// </summary>
[MessagePackObject]
public sealed class SignalResponseBinaryMessage : ISignalResponseMessage<byte[]>
{
[Key(0)] public int MessageTag { get; set; }
[Key(1)] public SignalResponseStatus Status { get; set; }
[Key(2)] public byte[]? ResponseData { get; set; }
[IgnoreMember]
public string? ResponseDataJson => ResponseData != null ? Convert.ToBase64String(ResponseData) : null;
public SignalResponseBinaryMessage() { }
public SignalResponseBinaryMessage(int messageTag, SignalResponseStatus status)
{
Status = status;
MessageTag = messageTag;
}
public SignalResponseBinaryMessage(int messageTag, SignalResponseStatus status, object? responseData, AcBinarySerializerOptions? options = null)
: this(messageTag, status)
{
if (responseData == null)
{
ResponseData = null;
return;
}
// If responseData is already a byte array, use it directly
if (responseData is byte[] byteData)
{
ResponseData = byteData;
return;
}
// Serialize to binary
ResponseData = options != null
? responseData.ToBinary(options)
: responseData.ToBinary();
}
}
public interface IAcSignalRHubClient : IAcSignalRHubBase
{
Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId );

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 ResponseDateTime;
public object? ResponseByRequestId = null;
public object? ResponseByRequestId;
public SignalRRequestModel()
{
@ -16,4 +22,59 @@ public class SignalRRequestModel
ResponseByRequestId = responseByRequestId;
}
/// <summary>
/// Resets the model for reuse from the pool.
/// </summary>
public bool TryReset()
{
RequestDateTime = default;
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 and initializes it.
/// </summary>
public static SignalRRequestModel Get()
{
var model = Pool.Get();
model.Initialize();
return model;
}
/// <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

@ -6,8 +6,8 @@
<Import Project="..//AyCode.Core.targets" />
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" Version="2025.2.2" />
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="9.0.10" />
<PackageReference Include="JetBrains.Annotations" Version="2025.2.4" />
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="9.0.11" />
</ItemGroup>
<ItemGroup>

15
Directory.Build.props Normal file
View File

@ -0,0 +1,15 @@
<Project>
<PropertyGroup>
<!-- Centralized results directories -->
<Test_Benchmark_ResultsDir>$(MSBuildThisFileDirectory)Test_Benchmark_Results</Test_Benchmark_ResultsDir>
<TestResultsDirectory>$(Test_Benchmark_ResultsDir)\MSTest</TestResultsDirectory>
<!-- Provide a property for benchmark artifacts to be used by Benchmark projects if referenced -->
<BenchmarkArtifactsPath>$(Test_Benchmark_ResultsDir)\Benchmark</BenchmarkArtifactsPath>
<!-- Global runsettings file (placed at repository root) used by dotnet test when configured -->
<RunSettingsFile>$(MSBuildThisFileDirectory)test.runsettings</RunSettingsFile>
<!-- Expose MSBuild property that test runners may use -->
<VSTestResultsDirectory>$(TestResultsDirectory)</VSTestResultsDirectory>
</PropertyGroup>
</Project>

8
test.runsettings Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<RunConfiguration>
<!-- Centralized test results directory for MSTest -->
<ResultsDirectory>Test_Benchmark_Results\MSTest</ResultsDirectory>
<!-- Default test timeout and other settings can be added here -->
</RunConfiguration>
</RunSettings>