Compare commits
18 Commits
da5ba340f7
...
60238952d8
| Author | SHA1 | Date |
|---|---|---|
|
|
60238952d8 | |
|
|
9f1c31bd15 | |
|
|
056aae97a5 | |
|
|
f69b14c195 | |
|
|
6faed09f9f | |
|
|
1a9e760b68 | |
|
|
09a4604e52 | |
|
|
2147d981db | |
|
|
b9e83e2ef8 | |
|
|
a945db9b09 | |
|
|
ad426feba4 | |
|
|
8e7869b3da | |
|
|
c29b3daa0e | |
|
|
5abff05031 | |
|
|
a0445e6d1e | |
|
|
f9dc9a65fb | |
|
|
166d97106d | |
|
|
f3ec941774 |
|
|
@ -372,4 +372,7 @@ MigrationBackup/
|
|||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
FodyWeavers.xsd
|
||||
/BenchmarkSuite1/Results
|
||||
/CoverageReport
|
||||
/Test_Benchmark_Results
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
using System.Security.Claims;
|
||||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Models.Server.DynamicMethods;
|
||||
using AyCode.Services.Server.SignalRs;
|
||||
using AyCode.Services.SignalRs;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.VSDiagnostics;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks for SignalR round-trip communication using the same infrastructure as SignalRClientToHubTest.
|
||||
/// Measures: Client -> Server -> Service -> Response -> Client
|
||||
/// </summary>
|
||||
[MemoryDiagnoser]
|
||||
[CPUUsageDiagnoser]
|
||||
public class SignalRRoundTripBenchmarks
|
||||
{
|
||||
private BenchmarkSignalRClient _client = null!;
|
||||
private BenchmarkSignalRHub _hub = null!;
|
||||
private BenchmarkSignalRService _service = null!;
|
||||
|
||||
// Pre-created test data
|
||||
private TestOrderItem _testOrderItem = null!;
|
||||
private TestOrder _testOrder = null!;
|
||||
private SharedTag _sharedTag = null!;
|
||||
private int[] _intArray = null!;
|
||||
private List<string> _stringList = null!;
|
||||
private Guid _testGuid;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
var logger = new TestLogger();
|
||||
_hub = new BenchmarkSignalRHub(logger);
|
||||
_service = new BenchmarkSignalRService();
|
||||
_client = new BenchmarkSignalRClient(_hub, logger);
|
||||
_hub.RegisterService(_service, _client);
|
||||
|
||||
// Pre-create test data
|
||||
_testOrderItem = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10.50m };
|
||||
_testOrder = TestDataFactory.CreateOrder(itemCount: 3);
|
||||
_sharedTag = new SharedTag { Id = 1, Name = "Important", Color = "#FF0000" };
|
||||
_intArray = [1, 2, 3, 4, 5];
|
||||
_stringList = ["apple", "banana", "cherry"];
|
||||
_testGuid = Guid.NewGuid();
|
||||
}
|
||||
|
||||
#region Primitive Parameter Benchmarks
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Single int")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public string? RoundTrip_SingleInt()
|
||||
{
|
||||
return _client.PostDataSync<int, string>(BenchmarkSignalRTags.SingleIntParam, 42);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Two ints")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public int RoundTrip_TwoInts()
|
||||
{
|
||||
return _client.PostSync<int>(BenchmarkSignalRTags.TwoIntParams, [10, 20]);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Bool")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public bool RoundTrip_Bool()
|
||||
{
|
||||
return _client.PostDataSync<bool, bool>(BenchmarkSignalRTags.BoolParam, true);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: String")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public string? RoundTrip_String()
|
||||
{
|
||||
return _client.PostDataSync<string, string>(BenchmarkSignalRTags.StringParam, "Hello");
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Guid")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public Guid RoundTrip_Guid()
|
||||
{
|
||||
return _client.PostDataSync<Guid, Guid>(BenchmarkSignalRTags.GuidParam, _testGuid);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: No params")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public string? RoundTrip_NoParams()
|
||||
{
|
||||
return _client.GetAllSync<string>(BenchmarkSignalRTags.NoParams);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Multiple types (3 params)")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public string? RoundTrip_MultipleTypes()
|
||||
{
|
||||
return _client.PostSync<string>(BenchmarkSignalRTags.MultipleTypesParams, [true, "test", 42]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complex Object Benchmarks
|
||||
|
||||
[Benchmark(Description = "RoundTrip: TestOrderItem")]
|
||||
[BenchmarkCategory("Complex")]
|
||||
public TestOrderItem? RoundTrip_TestOrderItem()
|
||||
{
|
||||
return _client.PostDataSync<TestOrderItem, TestOrderItem>(BenchmarkSignalRTags.TestOrderItemParam, _testOrderItem);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: TestOrder (3 items)")]
|
||||
[BenchmarkCategory("Complex")]
|
||||
public TestOrder? RoundTrip_TestOrder()
|
||||
{
|
||||
return _client.PostDataSync<TestOrder, TestOrder>(BenchmarkSignalRTags.TestOrderParam, _testOrder);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: SharedTag")]
|
||||
[BenchmarkCategory("Complex")]
|
||||
public SharedTag? RoundTrip_SharedTag()
|
||||
{
|
||||
return _client.PostDataSync<SharedTag, SharedTag>(BenchmarkSignalRTags.SharedTagParam, _sharedTag);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Collection Benchmarks
|
||||
|
||||
[Benchmark(Description = "RoundTrip: int[] (5 elements)")]
|
||||
[BenchmarkCategory("Collections")]
|
||||
public int[]? RoundTrip_IntArray()
|
||||
{
|
||||
return _client.PostDataSync<int[], int[]>(BenchmarkSignalRTags.IntArrayParam, _intArray);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: List<string> (3 elements)")]
|
||||
[BenchmarkCategory("Collections")]
|
||||
public List<string>? RoundTrip_StringList()
|
||||
{
|
||||
return _client.PostDataSync<List<string>, List<string>>(BenchmarkSignalRTags.StringListParam, _stringList);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mixed Parameter Benchmarks
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Int + DTO")]
|
||||
[BenchmarkCategory("Mixed")]
|
||||
public string? RoundTrip_IntAndDto()
|
||||
{
|
||||
return _client.PostSync<string>(BenchmarkSignalRTags.IntAndDtoParam, [42, _testOrderItem]);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: 5 mixed params")]
|
||||
[BenchmarkCategory("Mixed")]
|
||||
public string? RoundTrip_FiveParams()
|
||||
{
|
||||
return _client.PostSync<string>(BenchmarkSignalRTags.FiveParams, [42, "hello", true, _testGuid, 99.99m]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Benchmark Infrastructure (minimal, reuses production code)
|
||||
|
||||
/// <summary>
|
||||
/// SignalR tags for benchmarks - matches TestSignalRTags structure
|
||||
/// </summary>
|
||||
public abstract class BenchmarkSignalRTags : AcSignalRTags
|
||||
{
|
||||
public const int SingleIntParam = 100;
|
||||
public const int TwoIntParams = 101;
|
||||
public const int BoolParam = 102;
|
||||
public const int StringParam = 103;
|
||||
public const int GuidParam = 104;
|
||||
public const int NoParams = 107;
|
||||
public const int MultipleTypesParams = 109;
|
||||
public const int TestOrderItemParam = 120;
|
||||
public const int TestOrderParam = 121;
|
||||
public const int SharedTagParam = 122;
|
||||
public const int IntArrayParam = 130;
|
||||
public const int StringListParam = 132;
|
||||
public const int IntAndDtoParam = 160;
|
||||
public const int FiveParams = 164;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark-optimized SignalR client with synchronous methods for accurate timing
|
||||
/// </summary>
|
||||
public class BenchmarkSignalRClient : AcSignalRClientBase, IAcSignalRHubItemServer
|
||||
{
|
||||
private readonly BenchmarkSignalRHub _hub;
|
||||
|
||||
public BenchmarkSignalRClient(BenchmarkSignalRHub hub, TestLogger logger) : base(logger)
|
||||
{
|
||||
_hub = hub;
|
||||
// Eliminate polling delay for benchmarks
|
||||
MsDelay = 0;
|
||||
MsFirstDelay = 0;
|
||||
}
|
||||
|
||||
// Synchronous wrappers for benchmarking (avoids async overhead measurement)
|
||||
public TResponse? PostDataSync<TPost, TResponse>(int tag, TPost data)
|
||||
=> PostDataAsync<TPost, TResponse>(tag, data).GetAwaiter().GetResult();
|
||||
|
||||
public TResponse? PostSync<TResponse>(int tag, object[] parameters)
|
||||
=> PostAsync<TResponse>(tag, parameters).GetAwaiter().GetResult();
|
||||
|
||||
public TResponse? GetAllSync<TResponse>(int tag)
|
||||
=> GetAllAsync<TResponse>(tag).GetAwaiter().GetResult();
|
||||
|
||||
protected override Task MessageReceived(int messageTag, byte[] messageBytes) => Task.CompletedTask;
|
||||
protected override HubConnectionState GetConnectionState() => HubConnectionState.Connected;
|
||||
protected override bool IsConnected() => true;
|
||||
protected override Task StartConnectionInternal() => Task.CompletedTask;
|
||||
protected override Task StopConnectionInternal() => Task.CompletedTask;
|
||||
protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask;
|
||||
|
||||
protected override async Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId)
|
||||
{
|
||||
await _hub.OnReceiveMessage(messageTag, messageBytes, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark-optimized SignalR hub
|
||||
/// </summary>
|
||||
public class BenchmarkSignalRHub : AcWebSignalRHubBase<BenchmarkSignalRTags, TestLogger>
|
||||
{
|
||||
private IAcSignalRHubItemServer _callerClient = null!;
|
||||
|
||||
public BenchmarkSignalRHub(TestLogger logger) : base(new ConfigurationBuilder().Build(), logger)
|
||||
{
|
||||
}
|
||||
|
||||
public void RegisterService(object service, IAcSignalRHubItemServer client)
|
||||
{
|
||||
_callerClient = client;
|
||||
DynamicMethodCallModels.Add(new AcDynamicMethodCallModel<SignalRAttribute>(service));
|
||||
}
|
||||
|
||||
protected override string GetConnectionId() => "benchmark-connection";
|
||||
protected override bool IsConnectionAborted() => false;
|
||||
protected override string? GetUserIdentifier() => "benchmark-user";
|
||||
protected override ClaimsPrincipal? GetUser() => null;
|
||||
|
||||
protected override Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId)
|
||||
=> SendMessageToClient(_callerClient, messageTag, message, requestId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark service handlers - same logic as TestSignalRService2
|
||||
/// </summary>
|
||||
public class BenchmarkSignalRService
|
||||
{
|
||||
[SignalR(BenchmarkSignalRTags.SingleIntParam)]
|
||||
public string HandleSingleInt(int value) => $"{value}";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.TwoIntParams)]
|
||||
public int HandleTwoInts(int a, int b) => a + b;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.BoolParam)]
|
||||
public bool HandleBool(bool value) => value;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.StringParam)]
|
||||
public string HandleString(string text) => $"Echo: {text}";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.GuidParam)]
|
||||
public Guid HandleGuid(Guid id) => id;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.NoParams)]
|
||||
public string HandleNoParams() => "OK";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.MultipleTypesParams)]
|
||||
public string HandleMultipleTypes(bool flag, string text, int number) => $"{flag}-{text}-{number}";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.TestOrderItemParam)]
|
||||
public TestOrderItem HandleTestOrderItem(TestOrderItem item) => new()
|
||||
{
|
||||
Id = item.Id,
|
||||
ProductName = $"Processed: {item.ProductName}",
|
||||
Quantity = item.Quantity * 2,
|
||||
UnitPrice = item.UnitPrice * 2,
|
||||
};
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.TestOrderParam)]
|
||||
public TestOrder HandleTestOrder(TestOrder order) => order;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.SharedTagParam)]
|
||||
public SharedTag HandleSharedTag(SharedTag tag) => tag;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.IntArrayParam)]
|
||||
public int[] HandleIntArray(int[] values) => values.Select(x => x * 2).ToArray();
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.StringListParam)]
|
||||
public List<string> HandleStringList(List<string> items) => items.Select(x => x.ToUpper()).ToList();
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.IntAndDtoParam)]
|
||||
public string HandleIntAndDto(int id, TestOrderItem item) => $"{id}-{item?.ProductName}";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.FiveParams)]
|
||||
public string HandleFiveParams(int a, string b, bool c, Guid d, decimal e) => $"{a}-{b}-{c}-{d}-{e}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
using AyCode.Core.Helpers;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using Microsoft.VSDiagnostics;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
[CPUUsageDiagnoser]
|
||||
public class TaskHelperBenchmarks
|
||||
{
|
||||
private volatile bool _flag;
|
||||
private int _counter;
|
||||
private Action _incrementAction = null !;
|
||||
private Func<int> _incrementFunc = null !;
|
||||
private Func<Task<int>> _incrementAsyncFunc = null !;
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
_incrementAction = () => _counter++;
|
||||
_incrementFunc = () => ++_counter;
|
||||
_incrementAsyncFunc = async () =>
|
||||
{
|
||||
await Task.Yield();
|
||||
return ++_counter;
|
||||
};
|
||||
}
|
||||
|
||||
[IterationSetup]
|
||||
public void IterationSetup()
|
||||
{
|
||||
_flag = true; // Pre-set for immediate success
|
||||
_counter = 0;
|
||||
}
|
||||
|
||||
#region WaitToAsync Benchmarks
|
||||
[Benchmark(Description = "WaitToAsync - immediate success")]
|
||||
[BenchmarkCategory("WaitToAsync")]
|
||||
public Task<bool> WaitToAsync_ImmediateSuccess() => TaskHelper.WaitToAsync(() => _flag, 1000, 1);
|
||||
[Benchmark(Description = "WaitToAsync - short timeout (100ms)")]
|
||||
[BenchmarkCategory("WaitToAsync")]
|
||||
public Task<bool> WaitToAsync_ShortTimeout() => TaskHelper.WaitToAsync(() => true, 100, 1);
|
||||
#endregion
|
||||
#region ToThreadPoolTask Benchmarks
|
||||
[Benchmark(Description = "ToThreadPoolTask - Action")]
|
||||
[BenchmarkCategory("ThreadPool")]
|
||||
public Task ToThreadPoolTask_Action() => _incrementAction.ToThreadPoolTask();
|
||||
[Benchmark(Description = "ToThreadPoolTask - Func<T>")]
|
||||
[BenchmarkCategory("ThreadPool")]
|
||||
public Task<int> ToThreadPoolTask_FuncT() => _incrementFunc.ToThreadPoolTask();
|
||||
[Benchmark(Description = "ToThreadPoolTask - Func<Task<T>>")]
|
||||
[BenchmarkCategory("ThreadPool")]
|
||||
public Task<int> ToThreadPoolTask_FuncTaskT() => _incrementAsyncFunc.ToThreadPoolTask();
|
||||
[Benchmark(Description = "Task.Run baseline - Action")]
|
||||
[BenchmarkCategory("ThreadPool")]
|
||||
public Task TaskRun_Action_Baseline() => Task.Run(_incrementAction);
|
||||
#endregion
|
||||
#region Timing Method Comparison
|
||||
[Benchmark(Description = "DateTime.UtcNow.Ticks")]
|
||||
[BenchmarkCategory("Timing")]
|
||||
public long DateTimeUtcNow_Ticks() => DateTime.UtcNow.Ticks;
|
||||
[Benchmark(Description = "Environment.TickCount64")]
|
||||
[BenchmarkCategory("Timing")]
|
||||
public long EnvironmentTickCount64() => Environment.TickCount64;
|
||||
[Benchmark(Description = "DateTime.UtcNow.AddMilliseconds")]
|
||||
[BenchmarkCategory("Timing")]
|
||||
public long DateTimeUtcNow_AddMilliseconds() => DateTime.UtcNow.AddMilliseconds(1000).Ticks;
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<int> for semantic $id/$ref serialization.
|
||||
/// </summary>
|
||||
public class SharedTag : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Color { get; set; } = "#000000";
|
||||
public int Priority { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared category - for hierarchical cross-reference testing.
|
||||
/// </summary>
|
||||
public class SharedCategory : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
public int? ParentCategoryId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared user reference - appears in many places to test $ref deduplication.
|
||||
/// </summary>
|
||||
public class SharedUser : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Username { get; set; } = "";
|
||||
public string Email { get; set; } = "";
|
||||
public string FirstName { get; set; } = "";
|
||||
public string LastName { get; set; } = "";
|
||||
public bool IsActive { get; set; } = true;
|
||||
public TestUserRole Role { get; set; } = TestUserRole.User;
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public UserPreferences? Preferences { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User preferences - non-IId nested object
|
||||
/// </summary>
|
||||
public class UserPreferences
|
||||
{
|
||||
public string Theme { get; set; } = "light";
|
||||
public string Language { get; set; } = "en-US";
|
||||
public bool NotificationsEnabled { get; set; } = true;
|
||||
public string? EmailDigestFrequency { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Non-IId Metadata (Newtonsoft numeric $id/$ref testing)
|
||||
|
||||
/// <summary>
|
||||
/// Non-IId metadata class - uses Newtonsoft PreserveReferencesHandling (numeric $id/$ref).
|
||||
/// Does NOT implement IId, so uses standard Newtonsoft reference tracking.
|
||||
/// </summary>
|
||||
public class MetadataInfo
|
||||
{
|
||||
public string Key { get; set; } = "";
|
||||
public string Value { get; set; } = "";
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Nested metadata for deep Newtonsoft reference testing
|
||||
/// </summary>
|
||||
public MetadataInfo? ChildMetadata { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 5-Level Test Hierarchy (Order -> Item -> Pallet -> Measurement -> Point)
|
||||
|
||||
/// <summary>
|
||||
/// Level 1: Main order - root of the hierarchy
|
||||
/// </summary>
|
||||
public class TestOrder : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OrderNumber { get; set; } = "";
|
||||
public TestStatus Status { get; set; } = TestStatus.Pending;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? PaidDateUtc { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
// Level 2 collection
|
||||
public List<TestOrderItem> Items { get; set; } = [];
|
||||
|
||||
// Shared reference properties (for $id/$ref testing)
|
||||
public SharedTag? PrimaryTag { get; set; }
|
||||
public SharedTag? SecondaryTag { get; set; }
|
||||
public SharedUser? Owner { get; set; }
|
||||
public SharedCategory? Category { get; set; }
|
||||
|
||||
// Collection of shared references
|
||||
public List<SharedTag> Tags { get; set; } = [];
|
||||
|
||||
// Non-IId metadata (for Newtonsoft $ref testing)
|
||||
public MetadataInfo? OrderMetadata { get; set; }
|
||||
public MetadataInfo? AuditMetadata { get; set; }
|
||||
public List<MetadataInfo> MetadataList { get; set; } = [];
|
||||
|
||||
// NoMerge collection for testing replace behavior
|
||||
[JsonNoMergeCollection]
|
||||
public List<TestOrderItem> NoMergeItems { get; set; } = [];
|
||||
|
||||
// Parent reference - 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
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
namespace AyCode.Core.Tests.TestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating test data hierarchies.
|
||||
/// Used by both unit tests and benchmarks.
|
||||
/// </summary>
|
||||
public static class TestDataFactory
|
||||
{
|
||||
private static int _idCounter = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Reset the ID counter (call in test setup)
|
||||
/// </summary>
|
||||
public static void ResetIdCounter() => _idCounter = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Get the next unique ID
|
||||
/// </summary>
|
||||
public static int NextId() => _idCounter++;
|
||||
|
||||
#region Simple Object Creation
|
||||
|
||||
/// <summary>
|
||||
/// Create a shared tag for cross-reference testing
|
||||
/// </summary>
|
||||
public static SharedTag CreateTag(string? name = null, string? color = null)
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new SharedTag
|
||||
{
|
||||
Id = id,
|
||||
Name = name ?? $"Tag-{id}",
|
||||
Color = color ?? $"#{id:X2}{(id * 10) % 256:X2}{(id * 20) % 256:X2}",
|
||||
Priority = id % 5,
|
||||
IsActive = id % 2 == 0,
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-id),
|
||||
Description = $"Description for tag {id}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a shared category
|
||||
/// </summary>
|
||||
public static SharedCategory CreateCategory(string? name = null, int? parentId = null)
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new SharedCategory
|
||||
{
|
||||
Id = id,
|
||||
Name = name ?? $"Category-{id}",
|
||||
Description = $"Category description {id}",
|
||||
SortOrder = id * 100,
|
||||
IsDefault = id == 1,
|
||||
ParentCategoryId = parentId,
|
||||
CreatedAt = DateTime.UtcNow.AddMonths(-id),
|
||||
UpdatedAt = DateTime.UtcNow.AddDays(-id)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a shared user for cross-reference testing
|
||||
/// </summary>
|
||||
public static SharedUser CreateUser(string? username = null, TestUserRole role = TestUserRole.User)
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new SharedUser
|
||||
{
|
||||
Id = id,
|
||||
Username = username ?? $"user{id}",
|
||||
Email = $"user{id}@test.com",
|
||||
FirstName = $"First{id}",
|
||||
LastName = $"Last{id}",
|
||||
IsActive = true,
|
||||
Role = role,
|
||||
LastLoginAt = DateTime.UtcNow.AddHours(-id),
|
||||
CreatedAt = DateTime.UtcNow.AddYears(-1),
|
||||
Preferences = new UserPreferences
|
||||
{
|
||||
Theme = id % 2 == 0 ? "dark" : "light",
|
||||
Language = "en-US",
|
||||
NotificationsEnabled = true,
|
||||
EmailDigestFrequency = "daily"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create metadata info (non-IId)
|
||||
/// </summary>
|
||||
public static MetadataInfo CreateMetadata(string? key = null, bool withChild = false)
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new MetadataInfo
|
||||
{
|
||||
Key = key ?? $"Meta-{id}",
|
||||
Value = $"MetaValue-{id}",
|
||||
Timestamp = DateTime.UtcNow.AddMinutes(-id * 10),
|
||||
ChildMetadata = withChild ? CreateMetadata($"Child-{id}", false) : null
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Hierarchy Creation (5 Levels)
|
||||
|
||||
/// <summary>
|
||||
/// Create a deep order hierarchy with configurable depth
|
||||
/// </summary>
|
||||
public static TestOrder CreateOrder(
|
||||
int itemCount = 2,
|
||||
int palletsPerItem = 2,
|
||||
int measurementsPerPallet = 2,
|
||||
int pointsPerMeasurement = 3,
|
||||
SharedTag? sharedTag = null,
|
||||
SharedUser? sharedUser = null,
|
||||
MetadataInfo? sharedMetadata = null)
|
||||
{
|
||||
var order = new TestOrder
|
||||
{
|
||||
Id = _idCounter++,
|
||||
OrderNumber = $"ORD-{_idCounter:D4}",
|
||||
Status = TestStatus.Pending,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
TotalAmount = 1000m + _idCounter * 100,
|
||||
PrimaryTag = sharedTag,
|
||||
SecondaryTag = sharedTag, // Same reference for $ref testing
|
||||
Owner = sharedUser,
|
||||
OrderMetadata = sharedMetadata,
|
||||
AuditMetadata = sharedMetadata // Same reference for Newtonsoft $ref
|
||||
};
|
||||
|
||||
if (sharedTag != null)
|
||||
{
|
||||
order.Tags.Add(sharedTag);
|
||||
}
|
||||
|
||||
for (int i = 0; i < itemCount; i++)
|
||||
{
|
||||
var item = CreateOrderItem(palletsPerItem, measurementsPerPallet, pointsPerMeasurement, sharedTag, sharedUser, sharedMetadata);
|
||||
item.ParentOrder = order;
|
||||
order.Items.Add(item);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an order item with pallets
|
||||
/// </summary>
|
||||
public static TestOrderItem CreateOrderItem(
|
||||
int palletCount = 2,
|
||||
int measurementsPerPallet = 2,
|
||||
int pointsPerMeasurement = 3,
|
||||
SharedTag? sharedTag = null,
|
||||
SharedUser? sharedUser = null,
|
||||
MetadataInfo? sharedMetadata = null)
|
||||
{
|
||||
var item = new TestOrderItem
|
||||
{
|
||||
Id = _idCounter++,
|
||||
ProductName = $"Product-{_idCounter}",
|
||||
Quantity = 10 + _idCounter,
|
||||
UnitPrice = 5.5m * _idCounter,
|
||||
Status = TestStatus.Pending,
|
||||
Tag = sharedTag,
|
||||
Assignee = sharedUser,
|
||||
ItemMetadata = sharedMetadata
|
||||
};
|
||||
|
||||
for (int i = 0; i < palletCount; i++)
|
||||
{
|
||||
var pallet = CreatePallet(measurementsPerPallet, pointsPerMeasurement, sharedMetadata);
|
||||
pallet.ParentItem = item;
|
||||
item.Pallets.Add(pallet);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a pallet with measurements
|
||||
/// </summary>
|
||||
public static TestPallet CreatePallet(
|
||||
int measurementCount = 2,
|
||||
int pointsPerMeasurement = 3,
|
||||
MetadataInfo? sharedMetadata = null)
|
||||
{
|
||||
var pallet = new TestPallet
|
||||
{
|
||||
Id = _idCounter++,
|
||||
PalletCode = $"PLT-{_idCounter:D4}",
|
||||
TrayCount = 5 + _idCounter % 10,
|
||||
Status = TestStatus.Pending,
|
||||
Weight = 100.5 + _idCounter,
|
||||
PalletMetadata = sharedMetadata
|
||||
};
|
||||
|
||||
for (int i = 0; i < measurementCount; i++)
|
||||
{
|
||||
var measurement = CreateMeasurement(pointsPerMeasurement);
|
||||
measurement.ParentPallet = pallet;
|
||||
pallet.Measurements.Add(measurement);
|
||||
}
|
||||
|
||||
return pallet;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a measurement with points
|
||||
/// </summary>
|
||||
public static TestMeasurement CreateMeasurement(int pointCount = 3)
|
||||
{
|
||||
var measurement = new TestMeasurement
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Name = $"Measurement-{_idCounter}",
|
||||
TotalWeight = 100.5 + _idCounter,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
for (int i = 0; i < pointCount; i++)
|
||||
{
|
||||
var point = CreateMeasurementPoint();
|
||||
point.ParentMeasurement = measurement;
|
||||
measurement.Points.Add(point);
|
||||
}
|
||||
|
||||
return measurement;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a measurement point
|
||||
/// </summary>
|
||||
public static TestMeasurementPoint CreateMeasurementPoint()
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new TestMeasurementPoint
|
||||
{
|
||||
Id = id,
|
||||
Label = $"Point-{id}",
|
||||
Value = 10.5 + (id * 0.1),
|
||||
MeasuredAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Benchmark Data Generation
|
||||
|
||||
/// <summary>
|
||||
/// Create a large graph for benchmarking with many cross-references.
|
||||
/// Creates approximately (itemCount * palletsPerItem * measurementsPerPallet * pointsPerMeasurement) objects.
|
||||
/// </summary>
|
||||
public static TestOrder CreateBenchmarkOrder(
|
||||
int itemCount = 5,
|
||||
int palletsPerItem = 4,
|
||||
int measurementsPerPallet = 3,
|
||||
int pointsPerMeasurement = 5)
|
||||
{
|
||||
ResetIdCounter();
|
||||
|
||||
// Create shared references that will be used throughout
|
||||
var sharedTags = Enumerable.Range(1, 10).Select(_ => CreateTag()).ToList();
|
||||
var sharedUser = CreateUser("benchuser", TestUserRole.Admin);
|
||||
var sharedMetadata = CreateMetadata("benchmark", withChild: true);
|
||||
|
||||
var order = new TestOrder
|
||||
{
|
||||
Id = _idCounter++,
|
||||
OrderNumber = $"BENCH-{_idCounter:D6}",
|
||||
Status = TestStatus.Processing,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
TotalAmount = 999999.99m,
|
||||
PrimaryTag = sharedTags[0],
|
||||
SecondaryTag = sharedTags[0],
|
||||
Owner = sharedUser,
|
||||
Category = CreateCategory("Benchmark"),
|
||||
OrderMetadata = sharedMetadata,
|
||||
AuditMetadata = sharedMetadata,
|
||||
Tags = sharedTags.Take(3).ToList()
|
||||
};
|
||||
|
||||
for (int i = 0; i < itemCount; i++)
|
||||
{
|
||||
var item = new TestOrderItem
|
||||
{
|
||||
Id = _idCounter++,
|
||||
ProductName = $"BenchProduct-{i}",
|
||||
Quantity = 100 + i * 10,
|
||||
UnitPrice = 25.99m + i,
|
||||
Status = (TestStatus)(i % 5),
|
||||
Tag = sharedTags[i % sharedTags.Count],
|
||||
Assignee = sharedUser,
|
||||
ItemMetadata = sharedMetadata
|
||||
};
|
||||
item.ParentOrder = order;
|
||||
|
||||
for (int p = 0; p < palletsPerItem; p++)
|
||||
{
|
||||
var pallet = new TestPallet
|
||||
{
|
||||
Id = _idCounter++,
|
||||
PalletCode = $"PLT-{i}-{p}",
|
||||
TrayCount = 10 + p,
|
||||
Status = (TestStatus)(p % 4),
|
||||
Weight = 500.0 + p * 50,
|
||||
PalletMetadata = sharedMetadata
|
||||
};
|
||||
pallet.ParentItem = item;
|
||||
|
||||
for (int m = 0; m < measurementsPerPallet; m++)
|
||||
{
|
||||
var measurement = new TestMeasurement
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Name = $"Meas-{i}-{p}-{m}",
|
||||
TotalWeight = 50.0 + m * 10,
|
||||
CreatedAt = DateTime.UtcNow.AddMinutes(-m)
|
||||
};
|
||||
measurement.ParentPallet = pallet;
|
||||
|
||||
for (int pt = 0; pt < pointsPerMeasurement; pt++)
|
||||
{
|
||||
var point = new TestMeasurementPoint
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Label = $"Pt-{i}-{p}-{m}-{pt}",
|
||||
Value = 1.0 + pt * 0.5,
|
||||
MeasuredAt = DateTime.UtcNow.AddSeconds(-pt)
|
||||
};
|
||||
point.ParentMeasurement = measurement;
|
||||
measurement.Points.Add(point);
|
||||
}
|
||||
pallet.Measurements.Add(measurement);
|
||||
}
|
||||
item.Pallets.Add(pallet);
|
||||
}
|
||||
order.Items.Add(item);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create primitive test data for all-types testing
|
||||
/// </summary>
|
||||
public static PrimitiveTestClass CreatePrimitiveTestData()
|
||||
{
|
||||
return new PrimitiveTestClass
|
||||
{
|
||||
IntValue = int.MaxValue,
|
||||
LongValue = long.MaxValue,
|
||||
DoubleValue = 3.14159265358979,
|
||||
DecimalValue = 12345.6789m,
|
||||
FloatValue = 1.5f,
|
||||
BoolValue = true,
|
||||
StringValue = "Test String ?? ????",
|
||||
GuidValue = Guid.Parse("12345678-1234-1234-1234-123456789abc"),
|
||||
DateTimeValue = new DateTime(2024, 12, 25, 12, 30, 45, DateTimeKind.Utc),
|
||||
EnumValue = TestStatus.Shipped,
|
||||
ByteValue = 255,
|
||||
ShortValue = short.MaxValue,
|
||||
NullableInt = 42,
|
||||
NullableIntNull = null
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using AyCode.Core.Enums;
|
||||
using AyCode.Core.Loggers;
|
||||
|
||||
namespace AyCode.Core.Tests.TestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Test logger that captures log messages for assertions.
|
||||
/// Does not require configuration or log writers.
|
||||
/// </summary>
|
||||
public class TestLogger : AcLoggerBase
|
||||
{
|
||||
public List<LogEntry> Logs { get; } = [];
|
||||
|
||||
public TestLogger() : base(AppType.Server, LogLevel.Detail, "TestLogger")
|
||||
{
|
||||
}
|
||||
|
||||
public override void Detail(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||
=> Logs.Add(new LogEntry(LogLevel.Detail, text, categoryName, memberName));
|
||||
|
||||
public override void Debug(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||
=> Logs.Add(new LogEntry(LogLevel.Debug, text, categoryName, memberName));
|
||||
|
||||
public override void Info(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||
=> Logs.Add(new LogEntry(LogLevel.Info, text, categoryName, memberName));
|
||||
|
||||
public override void Warning(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||
=> Logs.Add(new LogEntry(LogLevel.Warning, text, categoryName, memberName));
|
||||
|
||||
public override void Suggest(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||
=> Logs.Add(new LogEntry(LogLevel.Suggest, text, categoryName, memberName));
|
||||
|
||||
public override void Error(string? text, Exception? ex = null, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||
=> Logs.Add(new LogEntry(LogLevel.Error, text, categoryName, memberName, ex));
|
||||
|
||||
public void Clear() => Logs.Clear();
|
||||
|
||||
public bool HasErrorLogs => Logs.Any(l => l.Level == LogLevel.Error);
|
||||
public bool HasWarningLogs => Logs.Any(l => l.Level == LogLevel.Warning);
|
||||
public IEnumerable<LogEntry> ErrorLogs => Logs.Where(l => l.Level == LogLevel.Error);
|
||||
public IEnumerable<LogEntry> WarningLogs => Logs.Where(l => l.Level == LogLevel.Warning);
|
||||
|
||||
public IEnumerable<string> GetErrorMessages() => ErrorLogs.Select(l => $"{l.Text} {l.Exception?.Message}");
|
||||
public IEnumerable<string> GetAllMessages() => Logs.Select(l => l.ToString());
|
||||
}
|
||||
|
||||
public record LogEntry(
|
||||
LogLevel Level,
|
||||
string? Text,
|
||||
string? CategoryName = null,
|
||||
string? MemberName = null,
|
||||
Exception? Exception = null)
|
||||
{
|
||||
public override string ToString() => $"[{Level}] {Text}";
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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<T> 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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
//}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ namespace AyCode.Database.Tests.Internal
|
|||
{
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
//[TestMethod]
|
||||
public override void DatabaseExistsTest() => base.DatabaseExistsTest();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ using AyCode.Database.Tests.Users;
|
|||
|
||||
namespace AyCode.Database.Tests.Internal.Users;
|
||||
|
||||
[TestClass]
|
||||
//[TestClass]
|
||||
public sealed class UserDalTests : AcUserDalTestBase<UserDal, UserDbContext, User, Profile, UserToken, Company, UserToCompany, Address, EmailMessage>
|
||||
{
|
||||
[DataTestMethod]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,518 @@
|
|||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Services.SignalRs;
|
||||
|
||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Test service with SignalR-attributed methods for testing ProcessOnReceiveMessage.
|
||||
/// Uses shared DTOs from AyCode.Core.Tests.TestModels.
|
||||
/// </summary>
|
||||
public class TestSignalRService
|
||||
{
|
||||
#region Captured Values for Assertions
|
||||
|
||||
// Primitive captures
|
||||
public bool SingleIntMethodCalled { get; private set; }
|
||||
public int? ReceivedInt { get; private set; }
|
||||
|
||||
public bool TwoIntMethodCalled { get; private set; }
|
||||
public (int A, int B)? ReceivedTwoInts { get; private set; }
|
||||
|
||||
public bool BoolMethodCalled { get; private set; }
|
||||
public bool? ReceivedBool { get; private set; }
|
||||
|
||||
public bool StringMethodCalled { get; private set; }
|
||||
public string? ReceivedString { get; private set; }
|
||||
|
||||
public bool GuidMethodCalled { get; private set; }
|
||||
public Guid? ReceivedGuid { get; private set; }
|
||||
|
||||
public bool EnumMethodCalled { get; private set; }
|
||||
public TestStatus? ReceivedEnum { get; private set; }
|
||||
|
||||
public bool NoParamsMethodCalled { get; private set; }
|
||||
|
||||
public bool MultipleTypesMethodCalled { get; private set; }
|
||||
public (bool, string, int)? ReceivedMultipleTypes { get; private set; }
|
||||
|
||||
// Extended primitives
|
||||
public bool DecimalMethodCalled { get; private set; }
|
||||
public decimal? ReceivedDecimal { get; private set; }
|
||||
|
||||
public bool DateTimeMethodCalled { get; private set; }
|
||||
public DateTime? ReceivedDateTime { get; private set; }
|
||||
|
||||
public bool DoubleMethodCalled { get; private set; }
|
||||
public double? ReceivedDouble { get; private set; }
|
||||
|
||||
public bool LongMethodCalled { get; private set; }
|
||||
public long? ReceivedLong { get; private set; }
|
||||
|
||||
// Complex object captures (using shared DTOs)
|
||||
public bool TestOrderItemMethodCalled { get; private set; }
|
||||
public TestOrderItem? ReceivedTestOrderItem { get; private set; }
|
||||
|
||||
public bool TestOrderMethodCalled { get; private set; }
|
||||
public TestOrder? ReceivedTestOrder { get; private set; }
|
||||
|
||||
public bool SharedTagMethodCalled { get; private set; }
|
||||
public SharedTag? ReceivedSharedTag { get; private set; }
|
||||
|
||||
// Collection captures
|
||||
public bool IntArrayMethodCalled { get; private set; }
|
||||
public int[]? ReceivedIntArray { get; private set; }
|
||||
|
||||
public bool GuidArrayMethodCalled { get; private set; }
|
||||
public Guid[]? ReceivedGuidArray { get; private set; }
|
||||
|
||||
public bool StringListMethodCalled { get; private set; }
|
||||
public List<string>? ReceivedStringList { get; private set; }
|
||||
|
||||
public bool TestOrderItemListMethodCalled { get; private set; }
|
||||
public List<TestOrderItem>? ReceivedTestOrderItemList { get; private set; }
|
||||
|
||||
public bool IntListMethodCalled { get; private set; }
|
||||
public List<int>? ReceivedIntList { get; private set; }
|
||||
|
||||
public bool BoolArrayMethodCalled { get; private set; }
|
||||
public bool[]? ReceivedBoolArray { get; private set; }
|
||||
|
||||
public bool MixedWithArrayMethodCalled { get; private set; }
|
||||
public (bool, int[], string)? ReceivedMixedWithArray { get; private set; }
|
||||
|
||||
public bool NestedListMethodCalled { get; private set; }
|
||||
public List<List<int>>? ReceivedNestedList { get; private set; }
|
||||
|
||||
// Extended array captures for comprehensive testing
|
||||
public bool LongArrayMethodCalled { get; private set; }
|
||||
public long[]? ReceivedLongArray { get; private set; }
|
||||
|
||||
public bool DecimalArrayMethodCalled { get; private set; }
|
||||
public decimal[]? ReceivedDecimalArray { get; private set; }
|
||||
|
||||
public bool DateTimeArrayMethodCalled { get; private set; }
|
||||
public DateTime[]? ReceivedDateTimeArray { get; private set; }
|
||||
|
||||
public bool EnumArrayMethodCalled { get; private set; }
|
||||
public TestStatus[]? ReceivedEnumArray { get; private set; }
|
||||
|
||||
public bool DoubleArrayMethodCalled { get; private set; }
|
||||
public double[]? ReceivedDoubleArray { get; private set; }
|
||||
|
||||
public bool SharedTagArrayMethodCalled { get; private set; }
|
||||
public SharedTag[]? ReceivedSharedTagArray { get; private set; }
|
||||
|
||||
public bool DictionaryMethodCalled { get; private set; }
|
||||
public Dictionary<string, int>? ReceivedDictionary { get; private set; }
|
||||
|
||||
public bool ObjectArrayMethodCalled { get; private set; }
|
||||
public object[]? ReceivedObjectArray { get; private set; }
|
||||
|
||||
// Mixed parameter captures
|
||||
public bool IntAndDtoMethodCalled { get; private set; }
|
||||
public (int, TestOrderItem?)? ReceivedIntAndDto { get; private set; }
|
||||
|
||||
public bool DtoAndListMethodCalled { get; private set; }
|
||||
public (TestOrderItem?, List<int>?)? ReceivedDtoAndList { get; private set; }
|
||||
|
||||
public bool ThreeComplexParamsMethodCalled { get; private set; }
|
||||
public (TestOrderItem?, List<string>?, SharedTag?)? ReceivedThreeComplexParams { get; private set; }
|
||||
|
||||
public bool FiveParamsMethodCalled { get; private set; }
|
||||
public (int, string?, bool, Guid, decimal)? ReceivedFiveParams { get; private set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Primitive Parameter Handlers
|
||||
|
||||
[SignalR(TestSignalRTags.SingleIntParam)]
|
||||
public string HandleSingleInt(int value)
|
||||
{
|
||||
SingleIntMethodCalled = true;
|
||||
ReceivedInt = value;
|
||||
return $"Received: {value}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.TwoIntParams)]
|
||||
public int HandleTwoInts(int a, int b)
|
||||
{
|
||||
TwoIntMethodCalled = true;
|
||||
ReceivedTwoInts = (a, b);
|
||||
return a + b;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.BoolParam)]
|
||||
public bool HandleBool(bool loadRelations)
|
||||
{
|
||||
BoolMethodCalled = true;
|
||||
ReceivedBool = loadRelations;
|
||||
return loadRelations;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.StringParam)]
|
||||
public string HandleString(string text)
|
||||
{
|
||||
StringMethodCalled = true;
|
||||
ReceivedString = text;
|
||||
return $"Echo: {text}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.GuidParam)]
|
||||
public Guid HandleGuid(Guid id)
|
||||
{
|
||||
GuidMethodCalled = true;
|
||||
ReceivedGuid = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.EnumParam)]
|
||||
public TestStatus HandleEnum(TestStatus status)
|
||||
{
|
||||
EnumMethodCalled = true;
|
||||
ReceivedEnum = status;
|
||||
return status;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.NoParams)]
|
||||
public string HandleNoParams()
|
||||
{
|
||||
NoParamsMethodCalled = true;
|
||||
return "OK";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.MultipleTypesParams)]
|
||||
public string HandleMultipleTypes(bool flag, string text, int number)
|
||||
{
|
||||
MultipleTypesMethodCalled = true;
|
||||
ReceivedMultipleTypes = (flag, text, number);
|
||||
return $"{flag}-{text}-{number}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.ThrowsException)]
|
||||
public void HandleThrowsException()
|
||||
{
|
||||
throw new InvalidOperationException("Test exception");
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DecimalParam)]
|
||||
public decimal HandleDecimal(decimal value)
|
||||
{
|
||||
DecimalMethodCalled = true;
|
||||
ReceivedDecimal = value;
|
||||
return value * 2;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DateTimeParam)]
|
||||
public DateTime HandleDateTime(DateTime dateTime)
|
||||
{
|
||||
DateTimeMethodCalled = true;
|
||||
ReceivedDateTime = dateTime;
|
||||
return dateTime;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DoubleParam)]
|
||||
public double HandleDouble(double value)
|
||||
{
|
||||
DoubleMethodCalled = true;
|
||||
ReceivedDouble = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.LongParam)]
|
||||
public long HandleLong(long value)
|
||||
{
|
||||
LongMethodCalled = true;
|
||||
ReceivedLong = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complex Object Handlers (using shared DTOs)
|
||||
|
||||
[SignalR(TestSignalRTags.TestOrderItemParam)]
|
||||
public TestOrderItem HandleTestOrderItem(TestOrderItem item)
|
||||
{
|
||||
TestOrderItemMethodCalled = true;
|
||||
ReceivedTestOrderItem = item;
|
||||
return new TestOrderItem
|
||||
{
|
||||
Id = item.Id,
|
||||
ProductName = $"Processed: {item.ProductName}",
|
||||
Quantity = item.Quantity * 2,
|
||||
UnitPrice = item.UnitPrice
|
||||
};
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.TestOrderParam)]
|
||||
public TestOrder HandleTestOrder(TestOrder order)
|
||||
{
|
||||
TestOrderMethodCalled = true;
|
||||
ReceivedTestOrder = order;
|
||||
return order;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.SharedTagParam)]
|
||||
public SharedTag HandleSharedTag(SharedTag tag)
|
||||
{
|
||||
SharedTagMethodCalled = true;
|
||||
ReceivedSharedTag = tag;
|
||||
return tag;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Collection Parameter Handlers
|
||||
|
||||
[SignalR(TestSignalRTags.IntArrayParam)]
|
||||
public int[] HandleIntArray(int[] values)
|
||||
{
|
||||
IntArrayMethodCalled = true;
|
||||
ReceivedIntArray = values;
|
||||
return values.Select(x => x * 2).ToArray();
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.GuidArrayParam)]
|
||||
public Guid[] HandleGuidArray(Guid[] ids)
|
||||
{
|
||||
GuidArrayMethodCalled = true;
|
||||
ReceivedGuidArray = ids;
|
||||
return ids;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.StringListParam)]
|
||||
public List<string> HandleStringList(List<string> items)
|
||||
{
|
||||
StringListMethodCalled = true;
|
||||
ReceivedStringList = items;
|
||||
return items.Select(x => x.ToUpper()).ToList();
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.TestOrderItemListParam)]
|
||||
public List<TestOrderItem> HandleTestOrderItemList(List<TestOrderItem> items)
|
||||
{
|
||||
TestOrderItemListMethodCalled = true;
|
||||
ReceivedTestOrderItemList = items;
|
||||
return items;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.IntListParam)]
|
||||
public List<int> HandleIntList(List<int> numbers)
|
||||
{
|
||||
IntListMethodCalled = true;
|
||||
ReceivedIntList = numbers;
|
||||
return numbers.Select(x => x * 2).ToList();
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.BoolArrayParam)]
|
||||
public bool[] HandleBoolArray(bool[] flags)
|
||||
{
|
||||
BoolArrayMethodCalled = true;
|
||||
ReceivedBoolArray = flags;
|
||||
return flags;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.MixedWithArrayParam)]
|
||||
public string HandleMixedWithArray(bool flag, int[] numbers, string text)
|
||||
{
|
||||
MixedWithArrayMethodCalled = true;
|
||||
ReceivedMixedWithArray = (flag, numbers, text);
|
||||
return $"{flag}-[{string.Join(",", numbers)}]-{text}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.NestedListParam)]
|
||||
public List<List<int>> HandleNestedList(List<List<int>> nestedList)
|
||||
{
|
||||
NestedListMethodCalled = true;
|
||||
ReceivedNestedList = nestedList;
|
||||
return nestedList;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extended Array Parameter Handlers
|
||||
|
||||
[SignalR(TestSignalRTags.LongArrayParam)]
|
||||
public long[] HandleLongArray(long[] values)
|
||||
{
|
||||
LongArrayMethodCalled = true;
|
||||
ReceivedLongArray = values;
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DecimalArrayParam)]
|
||||
public decimal[] HandleDecimalArray(decimal[] values)
|
||||
{
|
||||
DecimalArrayMethodCalled = true;
|
||||
ReceivedDecimalArray = values;
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DateTimeArrayParam)]
|
||||
public DateTime[] HandleDateTimeArray(DateTime[] values)
|
||||
{
|
||||
DateTimeArrayMethodCalled = true;
|
||||
ReceivedDateTimeArray = values;
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.EnumArrayParam)]
|
||||
public TestStatus[] HandleEnumArray(TestStatus[] values)
|
||||
{
|
||||
EnumArrayMethodCalled = true;
|
||||
ReceivedEnumArray = values;
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DoubleArrayParam)]
|
||||
public double[] HandleDoubleArray(double[] values)
|
||||
{
|
||||
DoubleArrayMethodCalled = true;
|
||||
ReceivedDoubleArray = values;
|
||||
return values;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.SharedTagArrayParam)]
|
||||
public SharedTag[] HandleSharedTagArray(SharedTag[] tags)
|
||||
{
|
||||
SharedTagArrayMethodCalled = true;
|
||||
ReceivedSharedTagArray = tags;
|
||||
return tags;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DictionaryParam)]
|
||||
public Dictionary<string, int> HandleDictionary(Dictionary<string, int> dict)
|
||||
{
|
||||
DictionaryMethodCalled = true;
|
||||
ReceivedDictionary = dict;
|
||||
return dict;
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.ObjectArrayParam)]
|
||||
public object[] HandleObjectArray(object[] values)
|
||||
{
|
||||
ObjectArrayMethodCalled = true;
|
||||
ReceivedObjectArray = values;
|
||||
return values;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mixed Parameter Handlers
|
||||
|
||||
[SignalR(TestSignalRTags.IntAndDtoParam)]
|
||||
public string HandleIntAndDto(int id, TestOrderItem item)
|
||||
{
|
||||
IntAndDtoMethodCalled = true;
|
||||
ReceivedIntAndDto = (id, item);
|
||||
return $"{id}-{item?.ProductName}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.DtoAndListParam)]
|
||||
public string HandleDtoAndList(TestOrderItem item, List<int> numbers)
|
||||
{
|
||||
DtoAndListMethodCalled = true;
|
||||
ReceivedDtoAndList = (item, numbers);
|
||||
return $"{item?.ProductName}-[{string.Join(",", numbers ?? [])}]";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.ThreeComplexParams)]
|
||||
public string HandleThreeComplexParams(TestOrderItem item, List<string> tags, SharedTag sharedTag)
|
||||
{
|
||||
ThreeComplexParamsMethodCalled = true;
|
||||
ReceivedThreeComplexParams = (item, tags, sharedTag);
|
||||
return $"{item?.ProductName}-{tags?.Count}-{sharedTag?.Name}";
|
||||
}
|
||||
|
||||
[SignalR(TestSignalRTags.FiveParams)]
|
||||
public string HandleFiveParams(int a, string b, bool c, Guid d, decimal e)
|
||||
{
|
||||
FiveParamsMethodCalled = true;
|
||||
ReceivedFiveParams = (a, b, c, d, e);
|
||||
return $"{a}-{b}-{c}-{d}-{e}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
// Primitive captures
|
||||
SingleIntMethodCalled = false;
|
||||
ReceivedInt = null;
|
||||
TwoIntMethodCalled = false;
|
||||
ReceivedTwoInts = null;
|
||||
BoolMethodCalled = false;
|
||||
ReceivedBool = null;
|
||||
StringMethodCalled = false;
|
||||
ReceivedString = null;
|
||||
GuidMethodCalled = false;
|
||||
ReceivedGuid = null;
|
||||
EnumMethodCalled = false;
|
||||
ReceivedEnum = null;
|
||||
NoParamsMethodCalled = false;
|
||||
MultipleTypesMethodCalled = false;
|
||||
ReceivedMultipleTypes = null;
|
||||
DecimalMethodCalled = false;
|
||||
ReceivedDecimal = null;
|
||||
DateTimeMethodCalled = false;
|
||||
ReceivedDateTime = null;
|
||||
DoubleMethodCalled = false;
|
||||
ReceivedDouble = null;
|
||||
LongMethodCalled = false;
|
||||
ReceivedLong = null;
|
||||
|
||||
// Complex object captures
|
||||
TestOrderItemMethodCalled = false;
|
||||
ReceivedTestOrderItem = null;
|
||||
TestOrderMethodCalled = false;
|
||||
ReceivedTestOrder = null;
|
||||
SharedTagMethodCalled = false;
|
||||
ReceivedSharedTag = null;
|
||||
|
||||
// Collection captures
|
||||
IntArrayMethodCalled = false;
|
||||
ReceivedIntArray = null;
|
||||
GuidArrayMethodCalled = false;
|
||||
ReceivedGuidArray = null;
|
||||
StringListMethodCalled = false;
|
||||
ReceivedStringList = null;
|
||||
TestOrderItemListMethodCalled = false;
|
||||
ReceivedTestOrderItemList = null;
|
||||
IntListMethodCalled = false;
|
||||
ReceivedIntList = null;
|
||||
BoolArrayMethodCalled = false;
|
||||
ReceivedBoolArray = null;
|
||||
MixedWithArrayMethodCalled = false;
|
||||
ReceivedMixedWithArray = null;
|
||||
NestedListMethodCalled = false;
|
||||
ReceivedNestedList = null;
|
||||
|
||||
// Extended array captures
|
||||
LongArrayMethodCalled = false;
|
||||
ReceivedLongArray = null;
|
||||
DecimalArrayMethodCalled = false;
|
||||
ReceivedDecimalArray = null;
|
||||
DateTimeArrayMethodCalled = false;
|
||||
ReceivedDateTimeArray = null;
|
||||
EnumArrayMethodCalled = false;
|
||||
ReceivedEnumArray = null;
|
||||
DoubleArrayMethodCalled = false;
|
||||
ReceivedDoubleArray = null;
|
||||
SharedTagArrayMethodCalled = false;
|
||||
ReceivedSharedTagArray = null;
|
||||
DictionaryMethodCalled = false;
|
||||
ReceivedDictionary = null;
|
||||
ObjectArrayMethodCalled = false;
|
||||
ReceivedObjectArray = null;
|
||||
|
||||
// Mixed parameter captures
|
||||
IntAndDtoMethodCalled = false;
|
||||
ReceivedIntAndDto = null;
|
||||
DtoAndListMethodCalled = false;
|
||||
ReceivedDtoAndList = null;
|
||||
ThreeComplexParamsMethodCalled = false;
|
||||
ReceivedThreeComplexParams = null;
|
||||
FiveParamsMethodCalled = false;
|
||||
ReceivedFiveParams = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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<TestOrderItem> without async keyword.
|
||||
/// CRITICAL: This simulates the exact production bug scenario.
|
||||
/// </summary>
|
||||
[SignalR(TestSignalRTags.TaskFromResultTestOrderItemParam)]
|
||||
public Task<TestOrderItem> HandleTaskFromResultTestOrderItem(TestOrderItem item)
|
||||
{
|
||||
return Task.FromResult(new TestOrderItem
|
||||
{
|
||||
Id = item.Id,
|
||||
ProductName = $"FromResult: {item.ProductName}",
|
||||
Quantity = item.Quantity * 2,
|
||||
UnitPrice = item.UnitPrice * 2,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns Task<int> without async keyword.
|
||||
/// </summary>
|
||||
[SignalR(TestSignalRTags.TaskFromResultIntParam)]
|
||||
public Task<int> HandleTaskFromResultInt(int value)
|
||||
{
|
||||
return Task.FromResult(value * 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns non-generic Task using Task.CompletedTask.
|
||||
/// </summary>
|
||||
[SignalR(TestSignalRTags.TaskFromResultNoParams)]
|
||||
public Task HandleTaskFromResultNoParams()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#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<ClientCustomerDto>.
|
||||
/// </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<ClientOrderSimple>.
|
||||
/// </summary>
|
||||
[SignalR(TestSignalRTags.PropertyMismatchNestedListParam)]
|
||||
public List<ServerOrderWithExtras> HandlePropertyMismatchNestedList(List<ServerOrderWithExtras> orders)
|
||||
{
|
||||
return orders;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using AyCode.Core;
|
||||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Services.Server.SignalRs;
|
||||
using AyCode.Services.SignalRs;
|
||||
using AyCode.Services.Tests.SignalRs;
|
||||
using MessagePack.Resolvers;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Testable SignalR client that allows testing without real HubConnection.
|
||||
/// </summary>
|
||||
public class TestableSignalRClient2 : AcSignalRClientBase, IAcSignalRHubItemServer
|
||||
{
|
||||
private HubConnectionState _connectionState = HubConnectionState.Connected;
|
||||
private readonly TestableSignalRHub2 _signalRHub;
|
||||
|
||||
/// <summary>
|
||||
/// Testable SignalR client that allows testing without real HubConnection.
|
||||
/// </summary>
|
||||
public TestableSignalRClient2(TestableSignalRHub2 signalRHub, TestLogger logger) : base(logger)
|
||||
{
|
||||
MsDelay = 0;
|
||||
MsFirstDelay = 0;
|
||||
|
||||
_signalRHub = signalRHub;
|
||||
}
|
||||
|
||||
#region Override virtual methods for testing
|
||||
|
||||
protected override async Task MessageReceived(int messageTag, byte[] messageBytes)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected override HubConnectionState GetConnectionState() => _connectionState;
|
||||
|
||||
protected override bool IsConnected() => _connectionState == HubConnectionState.Connected;
|
||||
|
||||
protected override Task StartConnectionInternal()
|
||||
{
|
||||
_connectionState = HubConnectionState.Connected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task StopConnectionInternal()
|
||||
{
|
||||
_connectionState = HubConnectionState.Disconnected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask;
|
||||
|
||||
protected override async Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId)
|
||||
{
|
||||
await _signalRHub.OnReceiveMessage(messageTag, messageBytes, requestId);
|
||||
}
|
||||
#endregion
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// Re-export TestLogger from AyCode.Core.Tests for backward compatibility
|
||||
|
||||
namespace AyCode.Services.Server.Tests;
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
@ -5,14 +5,63 @@ namespace AyCode.Services.Server.SignalRs;
|
|||
|
||||
public static class ExtensionMethods
|
||||
{
|
||||
/// <summary>
|
||||
/// Invokes a method and properly unwraps Task/Task<T> results.
|
||||
/// Handles both async methods and methods returning Task directly (e.g., Task.FromResult).
|
||||
/// </summary>
|
||||
public static object? InvokeMethod(this MethodInfo methodInfo, object obj, params object[]? parameters)
|
||||
{
|
||||
if (methodInfo.GetCustomAttribute(typeof(AsyncStateMachineAttribute)) is AsyncStateMachineAttribute isAsyncTask)
|
||||
var result = methodInfo.Invoke(obj, parameters);
|
||||
|
||||
if (result == null)
|
||||
return null;
|
||||
|
||||
// Check if result is a Task (this handles both async methods AND Task.FromResult)
|
||||
if (result is Task task)
|
||||
{
|
||||
dynamic awaitable = methodInfo.Invoke(obj, parameters)!;
|
||||
return awaitable.GetAwaiter().GetResult();
|
||||
// Wait for task completion
|
||||
task.GetAwaiter().GetResult();
|
||||
|
||||
// Check if it's Task<T> to extract the actual result
|
||||
var taskType = task.GetType();
|
||||
if (taskType.IsGenericType)
|
||||
{
|
||||
// Get the Result property from Task<T>
|
||||
var resultProperty = taskType.GetProperty("Result");
|
||||
if (resultProperty != null)
|
||||
{
|
||||
return resultProperty.GetValue(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Non-generic Task - no result
|
||||
return null;
|
||||
}
|
||||
|
||||
return methodInfo.Invoke(obj, parameters);
|
||||
// Handle ValueTask<T>
|
||||
var type = result.GetType();
|
||||
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>))
|
||||
{
|
||||
// Convert ValueTask<T> to Task<T> and get result
|
||||
var asTaskMethod = type.GetMethod("AsTask");
|
||||
if (asTaskMethod != null)
|
||||
{
|
||||
var taskResult = (Task)asTaskMethod.Invoke(result, null)!;
|
||||
taskResult.GetAwaiter().GetResult();
|
||||
|
||||
var resultProperty = taskResult.GetType().GetProperty("Result");
|
||||
return resultProperty?.GetValue(taskResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-generic ValueTask
|
||||
if (result is ValueTask valueTask)
|
||||
{
|
||||
valueTask.AsTask().GetAwaiter().GetResult();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Not a Task - return directly
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="..//AyCode.Core.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="4.0.2" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="4.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AyCode.Core.Tests\AyCode.Core.Tests.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Services\AyCode.Services.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1 @@
|
|||
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,130 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Services.SignalRs;
|
||||
using MessagePack.Resolvers;
|
||||
|
||||
namespace AyCode.Services.Tests.SignalRs;
|
||||
|
||||
[TestClass]
|
||||
public class PostJsonDataMessageTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Debug_CreatePostMessage_ForInt()
|
||||
{
|
||||
// Test what CreatePostMessage produces for an int
|
||||
var message = CreatePostMessageTest(42);
|
||||
|
||||
Console.WriteLine($"Message type: {message.GetType().Name}");
|
||||
|
||||
if (message is SignalPostJsonDataMessage<IdMessage> idMsg)
|
||||
{
|
||||
Console.WriteLine($"PostDataJson: {idMsg.PostDataJson}");
|
||||
Console.WriteLine($"PostData.Ids.Count: {idMsg.PostData.Ids.Count}");
|
||||
Console.WriteLine($"PostData.Ids[0]: {idMsg.PostData.Ids[0]}");
|
||||
}
|
||||
|
||||
// Serialize to MessagePack
|
||||
var bytes = message.ToMessagePack(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"MessagePack bytes: {bytes.Length}");
|
||||
|
||||
// Deserialize as server would
|
||||
var deserialized = bytes.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"Deserialized PostDataJson: {deserialized.PostDataJson}");
|
||||
Console.WriteLine($"Deserialized PostData type: {deserialized.PostData?.GetType().Name}");
|
||||
Console.WriteLine($"Deserialized PostData.Ids.Count: {deserialized.PostData?.Ids.Count}");
|
||||
|
||||
Assert.IsNotNull(deserialized.PostData);
|
||||
Assert.AreEqual(1, deserialized.PostData.Ids.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(42)]
|
||||
[DataRow("45")]
|
||||
[DataRow(true)]
|
||||
public void IdMessage_FullRoundTrip_AnyParameter(object testValue)
|
||||
{
|
||||
dynamic GetValueByType(object value)
|
||||
{
|
||||
if (value is int valueInt) return valueInt;
|
||||
if (value is bool valueBool) return valueBool;
|
||||
if (value is string valueString) return valueString;
|
||||
|
||||
Assert.Fail($"Type of testValue not implemented");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 1: Client creates message for int parameter (like PostDataAsync<int, string>)
|
||||
Console.WriteLine("=== Step 1: Client creates message ===");
|
||||
|
||||
var idMessage = new IdMessage(GetValueByType(testValue));
|
||||
Console.WriteLine($"IdMessage.Ids[0]: '{idMessage.Ids[0]}'");
|
||||
|
||||
var clientMessage = new SignalPostJsonDataMessage<IdMessage>(idMessage);
|
||||
Console.WriteLine($"Client PostDataJson: '{clientMessage.PostDataJson}'");
|
||||
|
||||
// Step 2: Serialize to MessagePack (client sends)
|
||||
Console.WriteLine("\n=== Step 2: MessagePack serialization ===");
|
||||
var bytes = clientMessage.ToMessagePack(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"MessagePack bytes: {bytes.Length}");
|
||||
|
||||
// Step 3: Server deserializes
|
||||
Console.WriteLine("\n=== Step 3: Server deserializes ===");
|
||||
var serverMessage = bytes.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"Server PostDataJson: '{serverMessage.PostDataJson}'");
|
||||
Console.WriteLine($"Server PostData.Ids.Count: {serverMessage.PostData?.Ids.Count}");
|
||||
Console.WriteLine($"Server PostData.Ids[0]: '{serverMessage.PostData?.Ids[0]}'");
|
||||
|
||||
// Step 4: Server deserializes parameter
|
||||
Console.WriteLine("\n=== Step 4: Server deserializes parameter ===");
|
||||
var paramJson = serverMessage.PostData.Ids[0];
|
||||
Console.WriteLine($"Parameter JSON: '{paramJson}'");
|
||||
var paramValue = AcJsonDeserializer.Deserialize(paramJson, testValue.GetType());
|
||||
Console.WriteLine($"Deserialized int value: {paramValue}");
|
||||
|
||||
// Step 5: Service method returns string
|
||||
Console.WriteLine("\n=== Step 5: Service method returns ===");
|
||||
var serviceResult = $"{paramValue}"; // Like HandleSingleInt does
|
||||
Console.WriteLine($"Service result: '{serviceResult}'");
|
||||
|
||||
// Step 6: Server creates response
|
||||
Console.WriteLine("\n=== Step 6: Server creates response ===");
|
||||
var response = new SignalResponseJsonMessage(100, SignalResponseStatus.Success, serviceResult);
|
||||
Console.WriteLine($"Response.ResponseData: '{response.ResponseData}'");
|
||||
|
||||
// Step 7: Serialize response to MessagePack
|
||||
Console.WriteLine("\n=== Step 7: Response MessagePack ===");
|
||||
var responseBytes = response.ToMessagePack(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"Response MessagePack bytes: {responseBytes.Length}");
|
||||
|
||||
// Step 8: Client deserializes response
|
||||
Console.WriteLine("\n=== Step 8: Client deserializes response ===");
|
||||
var clientResponse = responseBytes.MessagePackTo<SignalResponseJsonMessage>(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"Client ResponseData: '{clientResponse.ResponseData}'");
|
||||
|
||||
// Step 9: Client deserializes to target type (string)
|
||||
Console.WriteLine("\n=== Step 9: Client deserializes to string ===");
|
||||
try
|
||||
{
|
||||
var finalResult = clientResponse.ResponseData.JsonTo<string>();
|
||||
|
||||
Console.WriteLine($"Final result: '{finalResult}'");
|
||||
Assert.AreEqual(GetValueByType(testValue).ToString(), finalResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"ERROR: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static ISignalRMessage CreatePostMessageTest<TPostData>(TPostData postData)
|
||||
{
|
||||
var type = typeof(TPostData);
|
||||
|
||||
if (type == typeof(string) || type.IsEnum || type.IsValueType || type == typeof(DateTime))
|
||||
{
|
||||
return new SignalPostJsonDataMessage<IdMessage>(new IdMessage(postData!));
|
||||
}
|
||||
|
||||
return new SignalPostJsonDataMessage<TPostData>(postData);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using AyCode.Services.SignalRs;
|
||||
|
||||
namespace AyCode.Services.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR message tags for client testing.
|
||||
/// </summary>
|
||||
public static class TestClientTags
|
||||
{
|
||||
// Basic operations
|
||||
public const int Ping = 1;
|
||||
public const int Echo = 2;
|
||||
public const int GetStatus = 3;
|
||||
|
||||
// CRUD operations
|
||||
public const int GetById = 10;
|
||||
public const int GetAll = 11;
|
||||
public const int Create = 12;
|
||||
public const int Update = 13;
|
||||
public const int Delete = 14;
|
||||
|
||||
// Complex operations
|
||||
public const int GetOrderWithItems = 20;
|
||||
public const int PostOrder = 21;
|
||||
public const int GetMultipleParams = 22;
|
||||
|
||||
// Error scenarios
|
||||
public const int NotFound = 100;
|
||||
public const int ServerError = 101;
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
using AyCode.Core;
|
||||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Services.SignalRs;
|
||||
using MessagePack.Resolvers;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace AyCode.Services.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Testable SignalR client that allows testing without real HubConnection.
|
||||
/// </summary>
|
||||
public class TestableSignalRClient : AcSignalRClientBase
|
||||
{
|
||||
private HubConnectionState _connectionState = HubConnectionState.Connected;
|
||||
private int? _nextRequestIdOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Messages sent to the server (captured for assertions).
|
||||
/// </summary>
|
||||
public List<SentClientMessage> SentMessages { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Received messages (captured for assertions).
|
||||
/// </summary>
|
||||
public List<ReceivedClientMessage> ReceivedMessages { get; } = [];
|
||||
|
||||
public TestableSignalRClient(TestLogger logger) : base(logger)
|
||||
{
|
||||
}
|
||||
|
||||
#region Override virtual methods for testing
|
||||
|
||||
protected override HubConnectionState GetConnectionState() => _connectionState;
|
||||
|
||||
protected override bool IsConnected() => _connectionState == HubConnectionState.Connected;
|
||||
|
||||
protected override Task StartConnectionInternal()
|
||||
{
|
||||
_connectionState = HubConnectionState.Connected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task StopConnectionInternal()
|
||||
{
|
||||
_connectionState = HubConnectionState.Disconnected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask;
|
||||
|
||||
protected override Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId)
|
||||
{
|
||||
SentMessages.Add(new SentClientMessage(messageTag, messageBytes, requestId));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override int GetNextRequestId()
|
||||
{
|
||||
if (_nextRequestIdOverride.HasValue)
|
||||
{
|
||||
var id = _nextRequestIdOverride.Value;
|
||||
_nextRequestIdOverride = id + 1; // Auto-increment for subsequent calls
|
||||
return id;
|
||||
}
|
||||
return AcDomain.NextUniqueInt32;
|
||||
}
|
||||
|
||||
protected override Task MessageReceived(int messageTag, byte[] messageBytes)
|
||||
{
|
||||
ReceivedMessages.Add(new ReceivedClientMessage(messageTag, messageBytes));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public test helpers (wrappers for protected methods)
|
||||
|
||||
/// <summary>
|
||||
/// Sets the simulated connection state.
|
||||
/// </summary>
|
||||
public void SetConnectionState(HubConnectionState state) => _connectionState = state;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the next request ID for deterministic testing.
|
||||
/// Will auto-increment for subsequent calls.
|
||||
/// </summary>
|
||||
public void SetNextRequestId(int id) => _nextRequestIdOverride = id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the pending requests dictionary (public wrapper for testing).
|
||||
/// </summary>
|
||||
public new System.Collections.Concurrent.ConcurrentDictionary<int, SignalRRequestModel> GetPendingRequests()
|
||||
=> base.GetPendingRequests();
|
||||
|
||||
/// <summary>
|
||||
/// Registers a pending request (public wrapper for testing).
|
||||
/// </summary>
|
||||
public new void RegisterPendingRequest(int requestId, SignalRRequestModel model)
|
||||
=> base.RegisterPendingRequest(requestId, model);
|
||||
|
||||
/// <summary>
|
||||
/// Clears pending requests (public wrapper for testing).
|
||||
/// </summary>
|
||||
public new void ClearPendingRequests() => base.ClearPendingRequests();
|
||||
|
||||
/// <summary>
|
||||
/// Simulates receiving a response from the server.
|
||||
/// </summary>
|
||||
public Task SimulateServerResponse(int requestId, int messageTag, SignalResponseStatus status, object? data = null)
|
||||
{
|
||||
var response = new SignalResponseJsonMessage(messageTag, status, data);
|
||||
var bytes = response.ToMessagePack(ContractlessStandardResolver.Options);
|
||||
return OnReceiveMessage(messageTag, bytes, requestId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates receiving a success response from the server.
|
||||
/// </summary>
|
||||
public Task SimulateSuccessResponse<T>(int requestId, int messageTag, T data)
|
||||
=> SimulateServerResponse(requestId, messageTag, SignalResponseStatus.Success, data);
|
||||
|
||||
/// <summary>
|
||||
/// Simulates receiving an error response from the server.
|
||||
/// </summary>
|
||||
public Task SimulateErrorResponse(int requestId, int messageTag)
|
||||
=> SimulateServerResponse(requestId, messageTag, SignalResponseStatus.Error);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last sent message.
|
||||
/// </summary>
|
||||
public SentClientMessage? LastSentMessage => SentMessages.LastOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Clears all captured messages.
|
||||
/// </summary>
|
||||
public void ClearMessages()
|
||||
{
|
||||
SentMessages.Clear();
|
||||
ReceivedMessages.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes OnReceiveMessage directly for testing.
|
||||
/// </summary>
|
||||
public Task InvokeOnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)
|
||||
=> OnReceiveMessage(messageTag, messageBytes, requestId);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a message sent from client to server.
|
||||
/// </summary>
|
||||
public record SentClientMessage(int MessageTag, byte[]? MessageBytes, int? RequestId)
|
||||
{
|
||||
/// <summary>
|
||||
/// Deserializes the message to IdMessage format.
|
||||
/// Works with both production SignalPostJsonDataMessage and test SignalRPostMessageDto.
|
||||
/// </summary>
|
||||
public IdMessage? AsIdMessage()
|
||||
{
|
||||
if (MessageBytes == null) return null;
|
||||
try
|
||||
{
|
||||
// First deserialize to get the PostDataJson string
|
||||
var msg = MessageBytes.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
|
||||
return msg.PostData;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback: try deserializing as raw JSON wrapper
|
||||
try
|
||||
{
|
||||
var rawMsg = MessageBytes.MessagePackTo<SignalPostJsonMessage>(ContractlessStandardResolver.Options);
|
||||
return rawMsg.PostDataJson?.JsonTo<IdMessage>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the message to a specific post data type.
|
||||
/// </summary>
|
||||
public T? AsPostData<T>() where T : class
|
||||
{
|
||||
if (MessageBytes == null) return null;
|
||||
try
|
||||
{
|
||||
var msg = MessageBytes.MessagePackTo<SignalPostJsonDataMessage<T>>(ContractlessStandardResolver.Options);
|
||||
return msg.PostData;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback: try deserializing as raw JSON wrapper
|
||||
try
|
||||
{
|
||||
var rawMsg = MessageBytes.MessagePackTo<SignalPostJsonMessage>(ContractlessStandardResolver.Options);
|
||||
return rawMsg.PostDataJson?.JsonTo<T>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a message received by the client.
|
||||
/// </summary>
|
||||
public record ReceivedClientMessage(int MessageTag, byte[] MessageBytes)
|
||||
{
|
||||
/// <summary>
|
||||
/// Deserializes the message as a response.
|
||||
/// </summary>
|
||||
public SignalResponseJsonMessage? AsResponse()
|
||||
{
|
||||
try
|
||||
{
|
||||
return MessageBytes.MessagePackTo<SignalResponseJsonMessage>(ContractlessStandardResolver.Options);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,7 @@
|
|||
public class AcSignalRTags
|
||||
{
|
||||
public const int None = 0;
|
||||
|
||||
public const int PingTag = 90001;
|
||||
public const int EchoTag = 90002;
|
||||
}
|
||||
|
|
@ -10,47 +10,51 @@ namespace AyCode.Services.SignalRs;
|
|||
|
||||
public class IdMessage
|
||||
{
|
||||
public List<string> Ids { get; private set; } = [];
|
||||
public List<string> Ids { get; private set; }
|
||||
|
||||
public IdMessage()
|
||||
{
|
||||
Ids = [];
|
||||
}
|
||||
|
||||
public IdMessage(IEnumerable<object> ids) : this()
|
||||
/// <summary>
|
||||
/// Creates IdMessage with multiple parameters serialized directly as JSON.
|
||||
/// Each parameter is serialized independently without array wrapping.
|
||||
/// Use object[] explicitly to pass multiple parameters.
|
||||
/// </summary>
|
||||
public IdMessage(object[] ids)
|
||||
{
|
||||
//Ids.AddRange(ids);
|
||||
Ids.AddRange(ids.Select(x =>
|
||||
// Pre-allocate capacity to avoid list resizing
|
||||
Ids = new List<string>(ids.Length);
|
||||
for (var i = 0; i < ids.Length; i++)
|
||||
{
|
||||
string item;
|
||||
|
||||
//if (x is Expression expr)
|
||||
//{
|
||||
// string aa = string.Empty;
|
||||
// var serializer = new ExpressionSerializer(new JsonSerializer());
|
||||
// try
|
||||
// {
|
||||
// aa = serializer.SerializeText(expr);
|
||||
// }
|
||||
// catch(Exception ex)
|
||||
// {
|
||||
// Console.WriteLine(ex);
|
||||
// }
|
||||
|
||||
// item = (new[] { aa }).ToJson();
|
||||
//}
|
||||
//else
|
||||
item = (new[] { x }).ToJson();
|
||||
|
||||
return item;
|
||||
}));
|
||||
Ids.Add(ids[i].ToJson());
|
||||
}
|
||||
}
|
||||
|
||||
public IdMessage(object id) : this(new object[] { id })
|
||||
/// <summary>
|
||||
/// Creates IdMessage with a single parameter serialized as JSON.
|
||||
/// Collections (List, Array, etc.) are serialized as a single JSON array.
|
||||
/// </summary>
|
||||
public IdMessage(object id)
|
||||
{
|
||||
// Pre-allocate for single item
|
||||
Ids = new List<string>(1) { id.ToJson() };
|
||||
}
|
||||
|
||||
public IdMessage(IEnumerable<Guid> ids) : this(ids.Cast<object>().ToArray())
|
||||
/// <summary>
|
||||
/// Creates IdMessage with multiple Guid parameters.
|
||||
/// Each Guid is serialized as a separate Id entry.
|
||||
/// </summary>
|
||||
public IdMessage(IEnumerable<Guid> ids)
|
||||
{
|
||||
// Materialize to array once to get count and avoid multiple enumeration
|
||||
var idsArray = ids as Guid[] ?? ids.ToArray();
|
||||
Ids = new List<string>(idsArray.Length);
|
||||
for (var i = 0; i < idsArray.Length; i++)
|
||||
{
|
||||
Ids.Add(idsArray[i].ToJson());
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
|
|
@ -59,17 +63,18 @@ public class IdMessage
|
|||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class SignalPostJsonMessage
|
||||
{
|
||||
[Key(0)]
|
||||
public string PostDataJson { get; set; }
|
||||
public string PostDataJson { get; set; } = "";
|
||||
|
||||
public SignalPostJsonMessage()
|
||||
{}
|
||||
protected SignalPostJsonMessage(string postDataJson) => PostDataJson = postDataJson;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
[MessagePackObject(AllowPrivate = false)]
|
||||
public class SignalPostJsonDataMessage<TPostDataType> : SignalPostJsonMessage, ISignalPostMessage<TPostDataType> //where TPostDataType : class
|
||||
{
|
||||
[IgnoreMember]
|
||||
|
|
@ -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 );
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue