Compare commits

...

8 Commits

Author SHA1 Message Date
Loretta 4b2d3f4e75 Add opt-in binary diagnostics for SignalR serialization
Introduces detailed, opt-in diagnostic logging for binary serialization and deserialization in SignalR server and client code. Adds type and binary-level diagnostics, including hex dumps and header parsing, to aid debugging of protocol and type mismatches. Improves string hash function in the binary deserializer to avoid collisions. Diagnostics are consistent and structured, and can be enabled independently on server, client, and response message classes. No impact on normal operation unless diagnostics are enabled.
2025-12-20 08:43:22 +01:00
Loretta cde2b5e529 Refactor serializer tests, fix deserializer bugs, add Gzip
Major overhaul of binary serializer/deserializer tests: split and expand test coverage for primitives, objects, navigation, generics, circular refs, and edge cases. Fix critical bugs in property skipping, string interning, type mismatch diagnostics, nullable assignment, and VarInt decoding. Add WASM-optimized deserialization options with string caching. Switch SignalR compression from Brotli to Gzip and introduce GzipHelper. Add comprehensive StockTaking test models and real-world bug reproductions. Improve diagnostics, test discovery, and add benchmark/utility scripts.
2025-12-19 19:29:12 +01:00
Loretta 762088caf7 Refactor and expand SignalR data source tests
Split SignalRDataSourceTests into multiple focused files and introduce an abstract base test class for comprehensive coverage. Add concrete test classes for all serializer and collection type combinations. Move data source implementations to separate files. Test coverage now includes edge cases, interface compliance, and advanced scenarios.
2025-12-15 19:28:20 +01:00
Loretta b8143e4897 Add FixStr encoding for short strings; SIMD bulk copy
Introduces FixStr encoding (type codes 34–65) for short ASCII/UTF8 strings up to 31 bytes, combining type and length in one byte for improved space and speed. Adds SIMD-optimized bulk copy methods for double, float, and Guid arrays. Updates deserializer to handle FixStr codes efficiently. Adjusts tiny int encoding range to free up FixStr space. Disables metadata and string interning in shallow copy options. Improves performance and reduces overhead for common serialization scenarios.
2025-12-15 17:21:18 +01:00
Loretta a832d8e86d Expand QuickBenchmark suite & add CLI scripts
Majorly enhanced QuickBenchmark.cs with new helper methods, standardized iteration count, and several comprehensive benchmarks comparing AcBinary (with/without reference handling) to MessagePack. Improved output formatting for clarity. Added RunQuickBenchmark.ps1 and .bat scripts for easy CLI execution and registered them as solution items. These changes make benchmarking more robust, readable, and user-friendly.
2025-12-15 12:00:03 +01:00
Loretta bc30a3aede Refactor: Add high-perf JSON serializer & merge support
- Introduced AcJsonSerializer/AcJsonDeserializer in AyCode.Core.Serializers.Jsons, optimized for IId<T> reference and circular reference handling.
- Added AcJsonSerializerOptions/AcSerializerOptions for configurable reference handling and max depth.
- Implemented fast-path streaming (Utf8JsonReader/Writer) with fallback to DOM for reference scenarios.
- Added type metadata/property accessor caching for performance.
- Provided robust object/collection population with merge semantics for IId<T> collections.
- Added AcJsonDeserializationException for detailed error reporting.
- Implemented UnifiedMergeContractResolver for Newtonsoft.Json, supporting JsonNoMergeCollectionAttribute to control merge behavior.
- Added IdAwareCollectionMergeConverter<TItem, TId> for merging IId<T> collections by ID.
- Included helpers for ID extraction and semantic ID generation.
- Added DeepPopulateWithMerge extension for deep merging.
- Optimized with frozen dictionaries, pre-encoded property names, and context pooling.
- Ensured compatibility with both System.Text.Json and Newtonsoft.Json.
2025-12-14 19:34:49 +01:00
Loretta b17c2df6c2 Add header-based string interning to AcBinarySerializer
- Support preloaded string intern table in binary header for efficient string interning and reduced output size.
- Add HeaderFlag_StringInternTable to BinaryTypeCode and update serializer/deserializer to handle intern tables.
- Simplify string interning logic: always intern eligible strings, remove candidate tracking.
- Refactor property name table and buffer management for clarity and efficiency.
- Remove obsolete interning/property name methods from serializer context.
- Add new output methods (ToArray, WriteTo, DetachResult) for buffer/result handling.
- Introduce QuickBenchmark.cs with benchmarks comparing AcBinarySerializer (with/without interning), JSON, and MessagePack, including repeated string scenarios.
2025-12-14 15:12:11 +01:00
Loretta 271f23d0f6 Enhance AcBinary: property filter, string interning, arrays
- Add property-level filtering via BinaryPropertyFilter delegate and context
- Improve string interning with new StringInternNew type code and promotion logic
- Optimize array and dictionary serialization for primitive types
- Expose strongly-typed property accessors for primitives and enums
- Add new benchmarks for serialization modes
- Refactor buffer pooling and cleanup code
- All new features are opt-in; maintains backward compatibility
2025-12-14 12:45:29 +01:00
67 changed files with 11194 additions and 3671 deletions

View File

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<title>BenchmarkDotNet Riportok (Dropdown)</title>
<style>
body { font-family: Segoe UI, Arial, sans-serif; margin: 2em; background: #f8f9fa; }
select { font-size: 1.1em; padding: 0.2em; }
.report-content { background: #fff; border: 1px solid #ccc; padding: 1em; margin-top: 1em; border-radius: 6px; box-shadow: 0 2px 8px #0001; min-height: 200px; }
h1 { font-size: 1.5em; }
.filename { color: #888; font-size: 0.95em; }
.compare { color: #007700; font-size: 1.1em; margin-bottom: 1em; }
.compare.negative { color: #bb2222; }
iframe { width: 100%; min-height: 600px; border: none; background: #fff; }
</style>
</head>
<body>
<h1>BenchmarkDotNet Riportok</h1>
<label for='reportSelect'>Válassz riportot:</label>
<select id='reportSelect'>
<option value='AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_486670772'>AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report</option>
<option value='AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_2144380973'>SwitcherRun_20251215T194244_217</option>
<option value='AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_742792311'>SwitcherRun_20251214T182029_626</option>
<option value='AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_889027207'>AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report</option>
<option value='AyCode.Core.Benchmarks.AcBinaryVsMessagePackFullBenchmark-report_839282233'>SwitcherRun_20251214T182029_626</option>
</select>
<div class='filename' id='filename'></div>
<div class='compare' id='compare'></div>
<div class='report-content'><iframe id='reportFrame'></iframe></div>
<script>
// Relatív riport fájlok
const reports = {
'AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_486670772': 'Test_Benchmark_Results/Benchmark/results/AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report.html',
'AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_2144380973': 'Test_Benchmark_Results/MemDiag/SwitcherRun_20251215T194244_217/results/AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report.html',
'AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_742792311': 'AyCode.Benchmark/Test_Benchmark_Results/MemDiag/SwitcherRun_20251214T182029_626/results/AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report.html',
'AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_889027207': 'AyCode.Benchmark/Test_Benchmark_Results/Benchmark/results/AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report.html',
'AyCode.Core.Benchmarks.AcBinaryVsMessagePackFullBenchmark-report_839282233': 'AyCode.Benchmark/Test_Benchmark_Results/MemDiag/SwitcherRun_20251214T182029_626/results/AyCode.Core.Benchmarks.AcBinaryVsMessagePackFullBenchmark-report.html',
};
const means = {
'AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_486670772': '',
'AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_2144380973': '',
'AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_742792311': '',
'AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_889027207': '',
'AyCode.Core.Benchmarks.AcBinaryVsMessagePackFullBenchmark-report_839282233': '',
};
const select = document.getElementById('reportSelect');
const frame = document.getElementById('reportFrame');
const filename = document.getElementById('filename');
const compare = document.getElementById('compare');
function showReport() {
const id = select.value;
if (reports[id]) {
frame.src = reports[id];
} else {
frame.srcdoc = '<i>Nincs tartalom.</i>';
}
const opt = select.options[select.selectedIndex];
filename.textContent = opt ? opt.text : '';
// Összehasonlítás az eggyel korábbival
const idx = select.selectedIndex;
if (idx < select.options.length - 1) {
const prevId = select.options[idx + 1].value;
const currMean = parseFloat(means[id].replace(',','.'));
const prevMean = parseFloat(means[prevId].replace(',','.'));
if (!isNaN(currMean) && !isNaN(prevMean) && prevMean > 0) {
const diff = currMean - prevMean;
const percent = (diff / prevMean * 100).toFixed(2);
const sign = percent > 0 ? '+' : '';
compare.textContent = Eltérés az előzőhöz képest: % ( vs );
compare.className = 'compare' + (percent > 0 ? ' negative' : '');
} else {
compare.textContent = '';
}
} else {
compare.textContent = '';
}
}
select.addEventListener('change', showReport);
window.onload = showReport;
</script>
</body>
</html>

View File

@ -7,6 +7,9 @@ using MessagePack;
using MessagePack.Resolvers;
using BenchmarkDotNet.Configs;
using System.IO;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Serializers.Binaries;
using System.Diagnostics;
namespace AyCode.Benchmark
{
@ -67,6 +70,12 @@ namespace AyCode.Benchmark
var config = ManualConfig.Create(DefaultConfig.Instance)
.WithArtifactsPath(benchmarkDir);
if (args.Length > 0 && args[0] == "--quick")
{
RunQuickBenchmark();
return;
}
if (args.Length > 0 && args[0] == "--test")
{
var (inDir, outDir) = CreateMSTestDeployDirs(mstestDir);
@ -112,6 +121,7 @@ namespace AyCode.Benchmark
}
Console.WriteLine("Usage:");
Console.WriteLine(" --quick Quick benchmark with tabular output (AcBinary vs MessagePack)");
Console.WriteLine(" --test Quick AcBinary test");
Console.WriteLine(" --testmsgpack Quick MessagePack test");
Console.WriteLine(" --minimal Minimal benchmark");
@ -134,6 +144,193 @@ namespace AyCode.Benchmark
}
}
/// <summary>
/// Quick benchmark comparing AcBinary vs MessagePack with tabular output.
/// Tests: WithRef, NoRef, Serialize, Deserialize, Populate, Merge
/// </summary>
static void RunQuickBenchmark(int iterations = 1000)
{
Console.WriteLine();
Console.WriteLine("????????????????????????????????????????????????????????????????????????????????");
Console.WriteLine("? AcBinary vs MessagePack Quick Benchmark ?");
Console.WriteLine("????????????????????????????????????????????????????????????????????????????????");
Console.WriteLine();
// 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);
var testOrder = TestDataFactory.CreateOrder(
itemCount: 3,
palletsPerItem: 3,
measurementsPerPallet: 3,
pointsPerMeasurement: 4,
sharedTag: sharedTag,
sharedUser: sharedUser,
sharedMetadata: sharedMeta);
// Options
var withRefOptions = new AcBinarySerializerOptions();
var noRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling();
var msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
// Warm up
Console.WriteLine("Warming up...");
for (int i = 0; i < 100; i++)
{
_ = AcBinarySerializer.Serialize(testOrder, withRefOptions);
_ = AcBinarySerializer.Serialize(testOrder, noRefOptions);
_ = MessagePackSerializer.Serialize(testOrder, msgPackOptions);
}
// Pre-serialize data for deserialization tests
var acBinaryWithRef = AcBinarySerializer.Serialize(testOrder, withRefOptions);
var acBinaryNoRef = AcBinarySerializer.Serialize(testOrder, noRefOptions);
var msgPackData = MessagePackSerializer.Serialize(testOrder, msgPackOptions);
Console.WriteLine($"Iterations: {iterations:N0}");
Console.WriteLine();
// Size comparison
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine("? SIZE COMPARISON ?");
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine("? Format ? Size (bytes) ? vs MessagePack ? Savings ?");
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine($"? AcBinary (WithRef) ? {acBinaryWithRef.Length,14:N0} ? {100.0 * acBinaryWithRef.Length / msgPackData.Length,13:F1}% ? {msgPackData.Length - acBinaryWithRef.Length,14:N0} ?");
Console.WriteLine($"? AcBinary (NoRef) ? {acBinaryNoRef.Length,14:N0} ? {100.0 * acBinaryNoRef.Length / msgPackData.Length,13:F1}% ? {msgPackData.Length - acBinaryNoRef.Length,14:N0} ?");
Console.WriteLine($"? MessagePack ? {msgPackData.Length,14:N0} ? {100.0,13:F1}% ? {"(baseline)",14} ?");
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine();
// Benchmark results storage
var results = new List<(string Operation, string Mode, double AcBinaryMs, double MsgPackMs)>();
// Serialize benchmarks
var sw = Stopwatch.StartNew();
// AcBinary WithRef Serialize
sw.Restart();
for (int i = 0; i < iterations; i++)
_ = AcBinarySerializer.Serialize(testOrder, withRefOptions);
var acWithRefSerialize = sw.Elapsed.TotalMilliseconds;
// AcBinary NoRef Serialize
sw.Restart();
for (int i = 0; i < iterations; i++)
_ = AcBinarySerializer.Serialize(testOrder, noRefOptions);
var acNoRefSerialize = sw.Elapsed.TotalMilliseconds;
// MessagePack Serialize
sw.Restart();
for (int i = 0; i < iterations; i++)
_ = MessagePackSerializer.Serialize(testOrder, msgPackOptions);
var msgPackSerialize = sw.Elapsed.TotalMilliseconds;
results.Add(("Serialize", "WithRef", acWithRefSerialize, msgPackSerialize));
results.Add(("Serialize", "NoRef", acNoRefSerialize, msgPackSerialize));
// Deserialize benchmarks
// AcBinary WithRef Deserialize
sw.Restart();
for (int i = 0; i < iterations; i++)
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryWithRef);
var acWithRefDeserialize = sw.Elapsed.TotalMilliseconds;
// AcBinary NoRef Deserialize
sw.Restart();
for (int i = 0; i < iterations; i++)
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryNoRef);
var acNoRefDeserialize = sw.Elapsed.TotalMilliseconds;
// MessagePack Deserialize
sw.Restart();
for (int i = 0; i < iterations; i++)
_ = MessagePackSerializer.Deserialize<TestOrder>(msgPackData, msgPackOptions);
var msgPackDeserialize = sw.Elapsed.TotalMilliseconds;
results.Add(("Deserialize", "WithRef", acWithRefDeserialize, msgPackDeserialize));
results.Add(("Deserialize", "NoRef", acNoRefDeserialize, msgPackDeserialize));
// Populate benchmark (AcBinary only)
sw.Restart();
for (int i = 0; i < iterations; i++)
{
var target = CreatePopulateTarget(testOrder);
AcBinaryDeserializer.Populate(acBinaryNoRef, target);
}
var acPopulate = sw.Elapsed.TotalMilliseconds;
results.Add(("Populate", "NoRef", acPopulate, 0)); // MessagePack doesn't have Populate
// PopulateMerge benchmark (AcBinary only)
sw.Restart();
for (int i = 0; i < iterations; i++)
{
var target = CreatePopulateTarget(testOrder);
AcBinaryDeserializer.PopulateMerge(acBinaryNoRef.AsSpan(), target);
}
var acMerge = sw.Elapsed.TotalMilliseconds;
results.Add(("Merge", "NoRef", acMerge, 0));
// Round-trip
var acWithRefRoundTrip = acWithRefSerialize + acWithRefDeserialize;
var acNoRefRoundTrip = acNoRefSerialize + acNoRefDeserialize;
var msgPackRoundTrip = msgPackSerialize + msgPackDeserialize;
results.Add(("Round-trip", "WithRef", acWithRefRoundTrip, msgPackRoundTrip));
results.Add(("Round-trip", "NoRef", acNoRefRoundTrip, msgPackRoundTrip));
// Print performance table
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine("? PERFORMANCE COMPARISON (lower is better) ?");
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine("? Operation ? AcBinary (ms) ? MessagePack ? Ratio ?");
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
foreach (var r in results)
{
var opName = $"{r.Operation} ({r.Mode})";
if (r.MsgPackMs > 0)
{
var ratio = r.AcBinaryMs / r.MsgPackMs;
var ratioStr = ratio < 1 ? $"{ratio:F2}x faster" : $"{ratio:F2}x slower";
Console.WriteLine($"? {opName,-24} ? {r.AcBinaryMs,14:F2} ? {r.MsgPackMs,14:F2} ? {ratioStr,14} ?");
}
else
{
Console.WriteLine($"? {opName,-24} ? {r.AcBinaryMs,14:F2} ? {"N/A",14} ? {"(unique)",14} ?");
}
}
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine();
// Summary
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine("? SUMMARY ?");
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
var sizeAdvantage = 100.0 - (100.0 * acBinaryNoRef.Length / msgPackData.Length);
Console.WriteLine($"? Size advantage: AcBinary is {sizeAdvantage:F1}% smaller than MessagePack ?");
var serializeRatio = acNoRefSerialize / msgPackSerialize;
var deserializeRatio = acNoRefDeserialize / msgPackDeserialize;
Console.WriteLine($"? Serialize (NoRef): AcBinary is {(serializeRatio < 1 ? $"{1/serializeRatio:F2}x faster" : $"{serializeRatio:F2}x slower"),-20} ?");
Console.WriteLine($"? Deserialize (NoRef): AcBinary is {(deserializeRatio < 1 ? $"{1/deserializeRatio:F2}x faster" : $"{deserializeRatio:F2}x slower"),-18} ?");
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine();
}
static TestOrder CreatePopulateTarget(TestOrder source)
{
var target = new TestOrder { Id = source.Id };
foreach (var item in source.Items)
{
target.Items.Add(new TestOrderItem { Id = item.Id });
}
return target;
}
static (string InDir, string OutDir) CreateMSTestDeployDirs(string mstestBase)
{
var user = Environment.UserName ?? "Deploy";

View File

@ -12,6 +12,8 @@ using MongoDB.Bson;
using MongoDB.Bson.IO;
using MongoDB.Bson.Serialization;
using System.IO;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Jsons;
namespace AyCode.Core.Benchmarks;
@ -465,4 +467,80 @@ public class SizeComparisonBenchmark
[Benchmark(Description = "Placeholder")]
public int Placeholder() => 1; // Just to make BenchmarkDotNet happy
}
public enum BinaryBenchmarkMode
{
Default,
NoReferenceHandling,
FastMode
}
public abstract class AcBinaryOptionsBenchmarkBase
{
protected TestOrder TestOrder = null!;
protected AcBinarySerializerOptions BinaryOptions = null!;
protected MessagePackSerializerOptions MsgPackOptions = null!;
protected byte[] AcBinaryData = null!;
protected byte[] MsgPackData = null!;
[Params(BinaryBenchmarkMode.Default, BinaryBenchmarkMode.NoReferenceHandling, BinaryBenchmarkMode.FastMode)]
public BinaryBenchmarkMode Mode { get; set; }
[GlobalSetup]
public void GlobalSetup()
{
TestDataFactory.ResetIdCounter();
TestOrder = TestDataFactory.CreateBenchmarkOrder(
itemCount: 4,
palletsPerItem: 3,
measurementsPerPallet: 3,
pointsPerMeasurement: 6);
BinaryOptions = CreateBinaryOptions(Mode);
MsgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
AcBinaryData = AcBinarySerializer.Serialize(TestOrder, BinaryOptions);
MsgPackData = MessagePackSerializer.Serialize(TestOrder, MsgPackOptions);
var ratio = MsgPackData.Length == 0 ? 0 : 100.0 * AcBinaryData.Length / MsgPackData.Length;
Console.WriteLine($"[BenchmarkSetup] Mode={Mode} | AcBinary={AcBinaryData.Length} bytes | MessagePack={MsgPackData.Length} bytes | Ratio={ratio:F1}%");
}
private static AcBinarySerializerOptions CreateBinaryOptions(BinaryBenchmarkMode mode) => mode switch
{
BinaryBenchmarkMode.Default => new AcBinarySerializerOptions(),
BinaryBenchmarkMode.NoReferenceHandling => AcBinarySerializerOptions.WithoutReferenceHandling(),
BinaryBenchmarkMode.FastMode => new AcBinarySerializerOptions
{
UseMetadata = false,
UseStringInterning = false,
UseReferenceHandling = false
},
_ => new AcBinarySerializerOptions()
};
}
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class AcBinaryOptionsSerializeBenchmark : AcBinaryOptionsBenchmarkBase
{
[Benchmark(Description = "MessagePack Serialize", Baseline = true)]
public byte[] Serialize_MessagePack() => MessagePackSerializer.Serialize(TestOrder, MsgPackOptions);
[Benchmark(Description = "AcBinary Serialize")]
public byte[] Serialize_AcBinary() => AcBinarySerializer.Serialize(TestOrder, BinaryOptions);
}
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class AcBinaryOptionsDeserializeBenchmark : AcBinaryOptionsBenchmarkBase
{
[Benchmark(Description = "MessagePack Deserialize", Baseline = true)]
public TestOrder? Deserialize_MessagePack() => MessagePackSerializer.Deserialize<TestOrder>(MsgPackData, MsgPackOptions);
[Benchmark(Description = "AcBinary Deserialize")]
public TestOrder? Deserialize_AcBinary() => AcBinaryDeserializer.Deserialize<TestOrder>(AcBinaryData);
}

View File

@ -1,4 +1,5 @@
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Tests.TestModels;
using BenchmarkDotNet.Attributes;
using MessagePack;

View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -0,0 +1,52 @@
using System.Buffers;
using System.Text;
using AyCode.Core.Compression;
namespace AyCode.Core.Tests.Compression;
[TestClass]
public class GzipHelperTests
{
[TestMethod]
public void CompressAndDecompress_StringRoundTrip_Succeeds()
{
var original = "SignalR payload for gzip";
var compressed = GzipHelper.Compress(original);
var decompressed = GzipHelper.DecompressToString(compressed);
Assert.IsNotNull(compressed);
Assert.AreNotEqual(0, compressed.Length);
Assert.AreEqual(original, decompressed);
}
[TestMethod]
public void DecompressToRentedBuffer_ReturnsOriginalBytes()
{
var payload = "{\"message\":\"gzip\"}";
var compressed = GzipHelper.Compress(payload);
var (buffer, length) = GzipHelper.DecompressToRentedBuffer(compressed);
try
{
Assert.IsTrue(length > 0);
var text = Encoding.UTF8.GetString(buffer, 0, length);
Assert.AreEqual(payload, text);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
[TestMethod]
public void IsGzipCompressed_ReturnsExpectedValues()
{
var compressed = GzipHelper.Compress("ping");
var nonCompressed = Encoding.UTF8.GetBytes("plain text");
Assert.IsTrue(GzipHelper.IsGzipCompressed(compressed));
Assert.IsFalse(GzipHelper.IsGzipCompressed(nonCompressed));
Assert.IsFalse(GzipHelper.IsGzipCompressed(Array.Empty<byte>()));
}
}

View File

@ -3,6 +3,7 @@ using AyCode.Core.Enums;
using AyCode.Core.Extensions;
using AyCode.Core.Interfaces;
using AyCode.Core.Loggers;
using AyCode.Core.Serializers.Jsons;
using Newtonsoft.Json;
using AyCode.Core.Tests.TestModels;

View File

@ -1,4 +1,5 @@
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Binaries;
namespace AyCode.Core.Tests.Serialization;

View File

@ -0,0 +1,124 @@
using AyCode.Core.Serializers.Binaries;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Basic serialization tests for primitive types.
/// </summary>
[TestClass]
public class AcBinarySerializerBasicTests
{
[TestMethod]
public void Serialize_Null_ReturnsSingleNullByte()
{
var result = AcBinarySerializer.Serialize<object?>(null);
Assert.AreEqual(1, result.Length);
Assert.AreEqual((byte)0, result[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);
Assert.AreEqual(value.UtcTicks, result.UtcTicks);
Assert.AreEqual(value.Offset, result.Offset);
}
}

View File

@ -0,0 +1,82 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Tests.Serialization;
[TestClass]
public class AcBinarySerializerBenchmarkTests
{
[TestMethod]
public void Serialize_BenchmarkOrder_RoundTrip()
{
var order = TestDataFactory.CreateBenchmarkOrder(itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 2, pointsPerMeasurement: 5);
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()
{
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);
}
[TestMethod]
public void Serialize_BenchmarkOrder_LargeData_RoundTrip()
{
var order = TestDataFactory.CreateBenchmarkOrder(itemCount: 10, palletsPerItem: 5, measurementsPerPallet: 3, pointsPerMeasurement: 10);
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);
// Verify nested structure
for (int i = 0; i < order.Items.Count; i++)
{
Assert.AreEqual(order.Items[i].Id, result.Items[i].Id);
Assert.AreEqual(order.Items[i].Pallets.Count, result.Items[i].Pallets.Count);
}
}
[TestMethod]
public void Serialize_BenchmarkOrder_WithStringInterning_SmallerThanWithout()
{
var order = TestDataFactory.CreateBenchmarkOrder(itemCount: 5, palletsPerItem: 3, measurementsPerPallet: 2, pointsPerMeasurement: 5);
var binaryWithInterning = AcBinarySerializer.Serialize(order, AcBinarySerializerOptions.Default);
var binaryWithoutInterning = AcBinarySerializer.Serialize(order, new AcBinarySerializerOptions { UseStringInterning = false });
// Note: String interning may not always result in smaller size due to header overhead
// The primary benefit is for larger datasets with many repeated strings
Console.WriteLine($"With interning: {binaryWithInterning.Length}, Without: {binaryWithoutInterning.Length}");
// Both should deserialize correctly regardless of size
var result1 = AcBinaryDeserializer.Deserialize<TestOrder>(binaryWithInterning);
var result2 = AcBinaryDeserializer.Deserialize<TestOrder>(binaryWithoutInterning);
Assert.IsNotNull(result1);
Assert.IsNotNull(result2);
Assert.AreEqual(order.Id, result1.Id);
Assert.AreEqual(order.Id, result2.Id);
Assert.AreEqual(order.Items.Count, result1.Items.Count);
Assert.AreEqual(order.Items.Count, result2.Items.Count);
}
}

View File

@ -0,0 +1,160 @@
using AyCode.Core.Extensions;
using static AyCode.Core.Tests.Serialization.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for circular reference handling with back-navigation properties.
/// </summary>
[TestClass]
public class AcBinarySerializerCircularReferenceTests
{
/// <summary>
/// CRITICAL TEST: Circular references with back-navigation properties.
/// This simulates the exact production scenario where:
/// - StockTaking has StockTakingItems collection
/// - StockTakingItem has StockTaking back-reference (circular!)
/// - StockTakingItem has Product navigation property
/// </summary>
[TestMethod]
public void Deserialize_CircularReference_ParentChildBackReference()
{
var parent = new CircularParent
{
Id = 1,
Name = "Parent",
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = DateTime.UtcNow,
Creator = 6,
Children = new List<CircularChild>()
};
var child = new CircularChild
{
Id = 10,
ParentId = 1,
Name = "Child",
Created = DateTime.UtcNow.AddHours(-1),
Modified = DateTime.UtcNow,
Parent = parent,
GrandChildren = new List<CircularGrandChild>()
};
var grandChild = new CircularGrandChild
{
Id = 100,
ChildId = 10,
CreatorId = 6,
ModifierId = null,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Child = child
};
child.GrandChildren.Add(grandChild);
parent.Children.Add(child);
var binary = parent.ToBinary();
var result = binary.BinaryTo<CircularParent>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual(6, result.Creator, "Creator should be 6");
Assert.AreEqual(parent.Created.Ticks, result.Created.Ticks,
$"Created mismatch. Expected: {parent.Created}, Got: {result.Created}");
Assert.IsNotNull(result.Children);
Assert.AreEqual(1, result.Children.Count);
var resultChild = result.Children[0];
Assert.AreEqual(10, resultChild.Id);
Assert.AreEqual(resultChild.Created.Ticks, child.Created.Ticks, "Child.Created should match");
Assert.IsNotNull(resultChild.Parent, "Child.Parent back-reference should be resolved");
Assert.AreEqual(1, resultChild.Parent.Id, "Back-reference should point to same parent");
}
/// <summary>
/// Test list of parents with circular references.
/// </summary>
[TestMethod]
public void Deserialize_ListOfCircularReferences_AllItemsCorrect()
{
var parents = Enumerable.Range(1, 5).Select(p =>
{
var parent = new CircularParent
{
Id = p,
Name = $"Parent_{p}",
Created = DateTime.UtcNow.AddDays(-p),
Modified = DateTime.UtcNow,
Creator = p,
Children = new List<CircularChild>()
};
for (int c = 1; c <= 2; c++)
{
var child = new CircularChild
{
Id = p * 100 + c,
ParentId = p,
Name = $"Child_{p}_{c}",
Created = DateTime.UtcNow.AddHours(-c),
Modified = DateTime.UtcNow,
Parent = parent,
GrandChildren = new List<CircularGrandChild>()
};
for (int g = 1; g <= 2; g++)
{
child.GrandChildren.Add(new CircularGrandChild
{
Id = p * 1000 + c * 100 + g,
ChildId = child.Id,
CreatorId = g % 2 == 0 ? p : null,
ModifierId = g % 2 == 1 ? p * 2 : null,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Child = child
});
}
parent.Children.Add(child);
}
return parent;
}).ToList();
var binary = parents.ToBinary();
var result = binary.BinaryTo<List<CircularParent>>();
Assert.IsNotNull(result);
Assert.AreEqual(5, result.Count);
for (int p = 0; p < 5; p++)
{
var original = parents[p];
var deserialized = result[p];
Assert.AreEqual(original.Id, deserialized.Id, $"Parent[{p}].Id mismatch");
Assert.AreEqual(original.Creator, deserialized.Creator, $"Parent[{p}].Creator mismatch");
Assert.AreEqual(original.Created.Ticks, deserialized.Created.Ticks,
$"Parent[{p}].Created mismatch. Expected: {original.Created}, Got: {deserialized.Created}");
Assert.IsNotNull(deserialized.Children, $"Parent[{p}].Children is null");
Assert.AreEqual(2, deserialized.Children.Count, $"Parent[{p}].Children.Count mismatch");
for (int c = 0; c < 2; c++)
{
var origChild = original.Children![c];
var deserChild = deserialized.Children[c];
Assert.AreEqual(origChild.Id, deserChild.Id, $"Parent[{p}].Children[{c}].Id mismatch");
Assert.AreEqual(origChild.Created.Ticks, deserChild.Created.Ticks,
$"Parent[{p}].Children[{c}].Created mismatch");
Assert.IsNotNull(deserChild.Parent, $"Parent[{p}].Children[{c}].Parent should not be null");
}
}
}
}

View File

@ -0,0 +1,188 @@
using AyCode.Core.Extensions;
using static AyCode.Core.Tests.Serialization.AcSerializerModels;
using static AyCode.Core.Tests.Serialization.AcSerializerTestHelper;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for DateTime type handling and potential type mismatch issues.
/// </summary>
[TestClass]
public class AcBinarySerializerDateTimeTests
{
[TestMethod]
public void Deserialize_DateTimeProperty_FromDifferentPropertyOrder_RoundTrip()
{
var entity = new TestEntityWithDateTimeAndInt
{
Id = 42,
IntValue = 100,
Created = new DateTime(2024, 12, 25, 10, 30, 45, DateTimeKind.Utc),
Modified = new DateTime(2024, 12, 26, 11, 45, 30, DateTimeKind.Utc),
StatusCode = 5,
Name = "TestEntity"
};
var binary = entity.ToBinary();
var result = binary.BinaryTo<TestEntityWithDateTimeAndInt>();
Assert.IsNotNull(result);
Assert.AreEqual(42, result.Id);
Assert.AreEqual(100, result.IntValue);
Assert.AreEqual(entity.Created, result.Created, "Created DateTime should match");
Assert.AreEqual(entity.Modified, result.Modified, "Modified DateTime should match");
Assert.AreEqual(5, result.StatusCode);
Assert.AreEqual("TestEntity", result.Name);
}
[TestMethod]
public void Deserialize_ListOfEntitiesWithDateTimeProperties_RoundTrip()
{
var entities = CreateDateTimeEntities(10);
var binary = entities.ToBinary();
var result = binary.BinaryTo<List<TestEntityWithDateTimeAndInt>>();
Assert.IsNotNull(result);
Assert.AreEqual(10, result.Count);
for (int i = 0; i < 10; i++)
{
var original = entities[i];
var deserialized = result[i];
Assert.AreEqual(original.Id, deserialized.Id, $"Id mismatch at index {i}");
Assert.AreEqual(original.IntValue, deserialized.IntValue, $"IntValue mismatch at index {i}");
Assert.AreEqual(original.Created.Ticks, deserialized.Created.Ticks, $"Created mismatch at index {i}");
Assert.AreEqual(original.Modified.Ticks, deserialized.Modified.Ticks, $"Modified mismatch at index {i}");
Assert.AreEqual(original.StatusCode, deserialized.StatusCode, $"StatusCode mismatch at index {i}");
Assert.AreEqual(original.Name, deserialized.Name, $"Name mismatch at index {i}");
}
}
[TestMethod]
public void Deserialize_EntityWithManyIntPropertiesBeforeDateTime_RoundTrip()
{
var entity = new TestEntityWithManyIntsBeforeDateTime
{
Id = 1,
Value1 = 10,
Value2 = 20,
Value3 = 30,
Value4 = 40,
Value5 = 50,
FirstDateTime = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc),
SecondDateTime = new DateTime(2024, 6, 20, 15, 30, 0, DateTimeKind.Utc),
FinalValue = 999
};
var binary = entity.ToBinary();
var result = binary.BinaryTo<TestEntityWithManyIntsBeforeDateTime>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual(10, result.Value1);
Assert.AreEqual(20, result.Value2);
Assert.AreEqual(30, result.Value3);
Assert.AreEqual(40, result.Value4);
Assert.AreEqual(50, result.Value5);
Assert.AreEqual(entity.FirstDateTime, result.FirstDateTime, "FirstDateTime should match");
Assert.AreEqual(entity.SecondDateTime, result.SecondDateTime, "SecondDateTime should match");
Assert.AreEqual(999, result.FinalValue);
}
[TestMethod]
public void Deserialize_NestedEntityWithDateTimeInChild_RoundTrip()
{
var parent = new TestParentEntityWithDateTimeChild
{
ParentId = 1,
ParentName = "Parent",
Child = new TestEntityWithDateTimeAndInt
{
Id = 100,
IntValue = 200,
Created = DateTime.UtcNow.AddDays(-5),
Modified = DateTime.UtcNow,
StatusCode = 3,
Name = "Child"
}
};
var binary = parent.ToBinary();
var result = binary.BinaryTo<TestParentEntityWithDateTimeChild>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.ParentId);
Assert.AreEqual("Parent", result.ParentName);
Assert.IsNotNull(result.Child);
Assert.AreEqual(100, result.Child.Id);
Assert.AreEqual(200, result.Child.IntValue);
Assert.AreEqual(parent.Child.Created.Ticks, result.Child.Created.Ticks, "Child.Created should match");
Assert.AreEqual(parent.Child.Modified.Ticks, result.Child.Modified.Ticks, "Child.Modified should match");
}
[TestMethod]
public void Deserialize_EntityWithCollectionContainingDateTimeItems_RoundTrip()
{
var parent = new TestParentWithDateTimeItemCollection
{
Id = 1,
Name = "Parent",
Created = DateTime.UtcNow.AddDays(-10),
Items = CreateDateTimeEntities(5)
};
var binary = parent.ToBinary();
var result = binary.BinaryTo<TestParentWithDateTimeItemCollection>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual("Parent", result.Name);
Assert.AreEqual(parent.Created.Ticks, result.Created.Ticks, "Parent.Created should match");
Assert.IsNotNull(result.Items);
Assert.AreEqual(5, result.Items.Count);
for (int i = 0; i < 5; i++)
{
var originalItem = parent.Items[i];
var deserializedItem = result.Items[i];
Assert.AreEqual(originalItem.Id, deserializedItem.Id, $"Items[{i}].Id should match");
Assert.AreEqual(originalItem.IntValue, deserializedItem.IntValue, $"Items[{i}].IntValue should match");
Assert.AreEqual(originalItem.Created.Ticks, deserializedItem.Created.Ticks, $"Items[{i}].Created should match");
Assert.AreEqual(originalItem.Modified.Ticks, deserializedItem.Modified.Ticks, $"Items[{i}].Modified should match");
}
}
[TestMethod]
public void Deserialize_ListOfParentEntitiesWithDateTimeChildCollections_RoundTrip()
{
var parents = CreateParentWithDateTimeItems(3, 3);
var binary = parents.ToBinary();
var result = binary.BinaryTo<List<TestParentWithDateTimeItemCollection>>();
Assert.IsNotNull(result);
Assert.AreEqual(3, result.Count);
for (int p = 0; p < 3; p++)
{
var originalParent = parents[p];
var deserializedParent = result[p];
Assert.AreEqual(originalParent.Id, deserializedParent.Id, $"Parent[{p}].Id should match");
Assert.AreEqual(originalParent.Name, deserializedParent.Name, $"Parent[{p}].Name should match");
Assert.AreEqual(originalParent.Created.Ticks, deserializedParent.Created.Ticks, $"Parent[{p}].Created should match");
Assert.IsNotNull(deserializedParent.Items);
Assert.AreEqual(3, deserializedParent.Items.Count, $"Parent[{p}].Items.Count should match");
for (int i = 0; i < 3; i++)
{
var originalItem = originalParent.Items![i];
var deserializedItem = deserializedParent.Items[i];
Assert.AreEqual(originalItem.Id, deserializedItem.Id, $"Parent[{p}].Items[{i}].Id should match");
Assert.AreEqual(originalItem.Created.Ticks, deserializedItem.Created.Ticks, $"Parent[{p}].Items[{i}].Created should match. Expected: {originalItem.Created}, Got: {deserializedItem.Created}");
}
}
}
}

View File

@ -0,0 +1,474 @@
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Binaries;
using System.Reflection;
using static AyCode.Core.Tests.Serialization.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Diagnostic tests to help debug serialization issues.
/// </summary>
[TestClass]
public class AcBinarySerializerDiagnosticTests
{
/// <summary>
/// Diagnostic test to understand the exact binary structure.
/// This test outputs the binary bytes to help debug production issues.
/// </summary>
[TestMethod]
public void Diagnostic_StockTaking_BinaryStructure()
{
var stockTaking = new TestStockTakingWithInheritance
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6,
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = null
};
var binary = stockTaking.ToBinary();
var hexDump = string.Join(" ", binary.Select(b => b.ToString("X2")));
Console.WriteLine($"Binary length: {binary.Length}");
Console.WriteLine($"Binary hex: {hexDump}");
for (int i = 0; i < binary.Length; i++)
{
if (binary[i] == 214)
{
Console.WriteLine($"Found 0xD6 at position {i}");
}
}
var result = binary.BinaryTo<TestStockTakingWithInheritance>();
Assert.IsNotNull(result);
Assert.AreEqual(6, result.Creator);
Assert.AreEqual(stockTaking.Created.Ticks, result.Created.Ticks);
}
/// <summary>
/// Test with nested list to ensure proper stream positioning.
/// </summary>
[TestMethod]
public void Diagnostic_StockTaking_WithNestedItems()
{
var stockTaking = new TestStockTakingWithInheritance
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6,
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = new List<TestStockTakingItemWithInheritance>
{
new()
{
Id = 10,
StockTakingId = 1,
ProductId = 100,
IsMeasured = true,
OriginalStockQuantity = 50,
MeasuredStockQuantity = 48,
Created = new DateTime(2025, 1, 24, 14, 0, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 14, 30, 0, DateTimeKind.Utc),
StockTakingItemPallets = null
}
}
};
var binary = stockTaking.ToBinary();
Console.WriteLine($"Binary length with 1 item: {binary.Length}");
var result = binary.BinaryTo<TestStockTakingWithInheritance>();
Assert.IsNotNull(result);
Assert.AreEqual(6, result.Creator, "Creator should be 6");
Assert.AreEqual(stockTaking.Created.Ticks, result.Created.Ticks,
$"Created mismatch. Expected: {stockTaking.Created}, Got: {result.Created}");
Assert.IsNotNull(result.StockTakingItems);
Assert.AreEqual(1, result.StockTakingItems.Count);
}
/// <summary>
/// CRITICAL TEST: Verify property order is consistent.
/// This test checks that the reflection-based property order matches
/// what's expected for serialization/deserialization.
/// </summary>
[TestMethod]
public void Diagnostic_PropertyOrder_InheritanceHierarchy()
{
var type = typeof(SimStockTaking);
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
Console.WriteLine($"Properties of {type.Name} (count: {props.Length}):");
for (int i = 0; i < props.Length; i++)
{
var prop = props[i];
Console.WriteLine($" [{i}] {prop.Name} : {prop.PropertyType.Name} (declared in: {prop.DeclaringType?.Name})");
}
// The exact order may vary by platform!
// Log it so we can compare server vs client
Assert.IsTrue(props.Length >= 7, "Should have at least 7 properties");
// Check that all expected properties exist
var propNames = props.Select(p => p.Name).ToHashSet();
Assert.IsTrue(propNames.Contains("Id"), "Should have Id");
Assert.IsTrue(propNames.Contains("StartDateTime"), "Should have StartDateTime");
Assert.IsTrue(propNames.Contains("IsClosed"), "Should have IsClosed");
Assert.IsTrue(propNames.Contains("Creator"), "Should have Creator");
Assert.IsTrue(propNames.Contains("Created"), "Should have Created");
Assert.IsTrue(propNames.Contains("Modified"), "Should have Modified");
Assert.IsTrue(propNames.Contains("StockTakingItems"), "Should have StockTakingItems");
}
/// <summary>
/// CRITICAL REGRESSION TEST: Simulates exact production hierarchy.
/// StockTaking : MgStockTaking&lt;StockTakingItem&gt; : MgEntityBase : BaseEntity
/// </summary>
[TestMethod]
public void Diagnostic_SimStockTaking_RoundTrip()
{
var stockTaking = new SimStockTaking
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6, // The exact value from production error
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = null // loadRelations = false means no items
};
var binary = stockTaking.ToBinary();
// Log the property names in the header
Console.WriteLine($"Binary length: {binary.Length}");
var result = binary.BinaryTo<SimStockTaking>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id, "Id should be 1");
Assert.AreEqual(6, result.Creator, "Creator should be 6 - this is where the bug occurs!");
Assert.AreEqual(stockTaking.Created.Ticks, result.Created.Ticks,
$"Created mismatch. Expected: {stockTaking.Created}, Got: {result.Created}");
Assert.AreEqual(stockTaking.StartDateTime, result.StartDateTime);
Assert.IsFalse(result.IsClosed);
}
/// <summary>
/// Test List of SimStockTaking - exact production scenario.
/// </summary>
[TestMethod]
public void Diagnostic_ListOfSimStockTaking_RoundTrip()
{
var stockTakings = Enumerable.Range(1, 3).Select(i => new SimStockTaking
{
Id = i,
StartDateTime = DateTime.UtcNow.AddDays(-i),
IsClosed = i % 2 == 0,
Creator = i,
Created = DateTime.UtcNow.AddDays(-i),
Modified = DateTime.UtcNow,
StockTakingItems = null
}).ToList();
var binary = stockTakings.ToBinary();
var result = binary.BinaryTo<List<SimStockTaking>>();
Assert.IsNotNull(result);
Assert.AreEqual(3, result.Count);
for (int i = 0; i < 3; i++)
{
var original = stockTakings[i];
var deserialized = result[i];
Assert.AreEqual(original.Id, deserialized.Id, $"[{i}] Id mismatch");
Assert.AreEqual(original.Creator, deserialized.Creator, $"[{i}] Creator mismatch");
Assert.AreEqual(original.Created.Ticks, deserialized.Created.Ticks,
$"[{i}] Created mismatch. Expected: {original.Created}, Got: {deserialized.Created}");
}
}
/// <summary>
/// Diagnostic: Check what PropertyType the reflection returns for generic type parameter.
/// </summary>
[TestMethod]
public void Diagnostic_GenericProperty_ReflectionType()
{
var parentType = typeof(ConcreteParent);
var itemsProp = parentType.GetProperty("Items");
Assert.IsNotNull(itemsProp);
var propType = itemsProp.PropertyType;
Console.WriteLine($"PropertyType: {propType}");
Console.WriteLine($"PropertyType.FullName: {propType.FullName}");
Console.WriteLine($"IsGenericType: {propType.IsGenericType}");
if (propType.IsGenericType)
{
var args = propType.GetGenericArguments();
Console.WriteLine($"GenericArguments.Length: {args.Length}");
foreach (var arg in args)
{
Console.WriteLine($" GenericArgument: {arg.FullName}");
}
}
Assert.IsTrue(propType.IsGenericType);
var elementType = propType.GetGenericArguments()[0];
Assert.AreEqual(typeof(GenericItemImpl), elementType,
"Element type should be GenericItemImpl, not IGenericItem");
}
/// <summary>
/// CRITICAL BUG REPRODUCTION: StockTakingItems is null (loadRelations = false)
/// This test verifies what happens when:
/// 1. Metadata header registers ALL properties including StockTakingItems
/// 2. Body SKIPS StockTakingItems because it's null
/// 3. Deserializer reads the body and must correctly map indices
/// </summary>
[TestMethod]
public void Diagnostic_NullStockTakingItems_VerifyPropertyIndices()
{
var stockTaking = new SimStockTaking
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6, // The exact value from production error (becomes TinyInt 0xD6)
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = null // THIS IS THE KEY - loadRelations = false
};
var binary = stockTaking.ToBinary();
// Log the binary structure
Console.WriteLine($"Binary length: {binary.Length}");
// Parse the header manually to understand structure
var pos = 0;
var version = binary[pos++];
Console.WriteLine($"Version: {version}");
var marker = binary[pos++];
Console.WriteLine($"Marker: 0x{marker:X2}");
// Read property count from metadata header
if ((marker & 0x10) != 0) // HasMetadata flag
{
var propCount = binary[pos++];
Console.WriteLine($"\n=== METADATA HEADER ===");
Console.WriteLine($"Property count in header: {propCount}");
for (int i = 0; i < propCount; i++)
{
var strLen = binary[pos++];
var propName = System.Text.Encoding.UTF8.GetString(binary, pos, strLen);
pos += strLen;
Console.WriteLine($" Header property [{i}]: '{propName}'");
}
}
Console.WriteLine($"\n=== BODY (starts at position {pos}) ===");
// The body should start with Object marker (0x19)
var bodyStart = pos;
var objectMarker = binary[pos++];
Console.WriteLine($"Object marker: 0x{objectMarker:X2} (expected 0x19 for Object)");
// Read ref ID (if reference handling is enabled)
// VarInt: if top bit is set, continue reading
var refIdByte = binary[pos];
int refId;
if ((refIdByte & 0x80) == 0)
{
refId = refIdByte;
pos++;
}
else
{
// Multi-byte VarInt - simplified parsing
refId = -1;
pos += 2; // Skip for now
}
Console.WriteLine($"RefId: {refId}");
// Read property count in body
var bodyPropCount = binary[pos++];
Console.WriteLine($"Property count in body: {bodyPropCount}");
Console.WriteLine($"\n=== BODY PROPERTIES ===");
for (int i = 0; i < bodyPropCount && pos < binary.Length; i++)
{
var propIndex = binary[pos++];
Console.WriteLine($" Body property [{i}]: index={propIndex}, next bytes: 0x{binary[pos]:X2} 0x{(pos + 1 < binary.Length ? binary[pos + 1] : 0):X2}");
// Skip the value (simplified - just log)
var valueType = binary[pos];
if (valueType == 0x14) // DateTime
{
Console.WriteLine($" -> DateTime (9 bytes)");
pos += 10; // type + 9 bytes
}
else if (valueType >= 0xD0 && valueType <= 0xE7) // TinyInt
{
var tinyValue = valueType - 0xD0;
Console.WriteLine($" -> TinyInt value: {tinyValue}");
pos += 1;
}
else if (valueType == 0x03) // False
{
Console.WriteLine($" -> Boolean: false");
pos += 1;
}
else if (valueType == 0x02) // True
{
Console.WriteLine($" -> Boolean: true");
pos += 1;
}
else
{
Console.WriteLine($" -> Unknown type: 0x{valueType:X2}");
break;
}
}
// Find where 0xD6 (Creator = 6) appears in the body
Console.WriteLine($"\n=== 0xD6 OCCURRENCES ===");
for (int i = bodyStart; i < binary.Length; i++)
{
if (binary[i] == 0xD6)
{
Console.WriteLine($"Found 0xD6 (TinyInt 6 = Creator value) at position {i}");
}
}
// Deserialize and verify
var result = binary.BinaryTo<SimStockTaking>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id, "Id should be 1");
Assert.AreEqual(6, result.Creator,
$"Creator should be 6. Got: {result.Creator}. " +
$"If this fails with a very large number, it means DateTime bytes were interpreted as int!");
Assert.AreEqual(stockTaking.Created, result.Created,
$"Created mismatch. Expected: {stockTaking.Created}, Got: {result.Created}. " +
$"If Created has wrong value, deserializer read wrong bytes!");
Assert.AreEqual(stockTaking.StartDateTime, result.StartDateTime);
Assert.IsFalse(result.IsClosed);
Assert.IsNull(result.StockTakingItems, "StockTakingItems should remain null");
}
/// <summary>
/// Test to verify property order consistency between serializer and deserializer.
/// </summary>
[TestMethod]
public void Diagnostic_VerifyPropertyOrderConsistency()
{
// Get serializer's property order
var serializerType = typeof(AcBinarySerializer);
var metadataCacheField = serializerType.GetField("TypeMetadataCache",
BindingFlags.NonPublic | BindingFlags.Static);
// Clear cache to force fresh metadata creation
// (This helps ensure we're testing the actual order)
var type = typeof(SimStockTaking);
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.ToArray();
Console.WriteLine($"Properties of {type.Name} (reflection order):");
for (int i = 0; i < props.Length; i++)
{
var prop = props[i];
Console.WriteLine($" [{i}] {prop.Name} : {prop.PropertyType.Name}");
}
// Verify Creator comes BEFORE Created in the reflection order
var creatorIndex = Array.FindIndex(props, p => p.Name == "Creator");
var createdIndex = Array.FindIndex(props, p => p.Name == "Created");
var stockTakingItemsIndex = Array.FindIndex(props, p => p.Name == "StockTakingItems");
Console.WriteLine($"\nKey indices:");
Console.WriteLine($" StockTakingItems: {stockTakingItemsIndex}");
Console.WriteLine($" Creator: {creatorIndex}");
Console.WriteLine($" Created: {createdIndex}");
// The bug scenario: if StockTakingItems is skipped during serialization,
// but the deserializer still expects it at the original index position,
// then Creator (index 3) would be read when expecting StockTakingItems (index 2)
// and Created (index 4) would be read when expecting Creator (index 3)
Assert.IsTrue(stockTakingItemsIndex >= 0, "StockTakingItems should exist");
Assert.IsTrue(creatorIndex >= 0, "Creator should exist");
Assert.IsTrue(createdIndex >= 0, "Created should exist");
// In the class definition order:
// StockTakingItems comes BEFORE Creator and Created
Assert.IsTrue(stockTakingItemsIndex < creatorIndex,
"StockTakingItems should come before Creator");
Assert.IsTrue(creatorIndex < createdIndex,
"Creator should come before Created");
}
/// <summary>
/// Test multiple StockTakings with null StockTakingItems - exact production scenario.
/// </summary>
[TestMethod]
public void Diagnostic_MultipleStockTakings_NullItems()
{
var stockTakings = new List<SimStockTaking>
{
new()
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6,
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = null
},
new()
{
Id = 2,
StartDateTime = new DateTime(2025, 1, 23, 9, 0, 0, DateTimeKind.Utc),
IsClosed = true,
Creator = 12,
Created = new DateTime(2025, 1, 23, 14, 0, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 23, 15, 30, 0, DateTimeKind.Utc),
StockTakingItems = null
}
};
var binary = stockTakings.ToBinary();
Console.WriteLine($"Binary length for 2 StockTakings: {binary.Length}");
var result = binary.BinaryTo<List<SimStockTaking>>();
Assert.IsNotNull(result);
Assert.AreEqual(2, result.Count);
// First item
Assert.AreEqual(1, result[0].Id);
Assert.AreEqual(6, result[0].Creator, "First item Creator should be 6");
Assert.AreEqual(stockTakings[0].Created, result[0].Created,
$"First item Created mismatch. Expected: {stockTakings[0].Created}, Got: {result[0].Created}");
// Second item
Assert.AreEqual(2, result[1].Id);
Assert.AreEqual(12, result[1].Creator, "Second item Creator should be 12");
Assert.AreEqual(stockTakings[1].Created, result[1].Created,
$"Second item Created mismatch. Expected: {stockTakings[1].Created}, Got: {result[1].Created}");
}
}

View File

@ -0,0 +1,161 @@
using AyCode.Core.Extensions;
using static AyCode.Core.Tests.Serialization.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for generic type parameter handling in serialization.
/// </summary>
[TestClass]
public class AcBinarySerializerGenericTypeTests
{
/// <summary>
/// CRITICAL REGRESSION TEST: Generic type parameter causing metadata mismatch.
///
/// The bug pattern:
/// 1. Parent class uses generic type parameter: GenericParent&lt;TItem&gt; where TItem : IGenericItem
/// 2. RegisterMetadataForType uses GetCollectionElementType which returns TItem (the interface/constraint)
/// 3. But serialization uses runtime type (GenericItemImpl) which has MORE properties
/// 4. Property indices in metadata table don't match what's being serialized
/// 5. Deserialization reads wrong property indices ? type mismatch!
/// </summary>
[TestMethod]
public void Deserialize_GenericTypeParameter_RuntimeTypeHasMoreProperties()
{
var parent = new ConcreteParent
{
Id = 1,
Name = "Parent",
Creator = 6,
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = DateTime.UtcNow,
Items = new List<GenericItemImpl>
{
new()
{
Id = 10,
Name = "Item1",
ExtraInt = 100,
Created = DateTime.UtcNow.AddHours(-1),
Modified = DateTime.UtcNow,
Description = "Description1"
},
new()
{
Id = 20,
Name = "Item2",
ExtraInt = 200,
Created = DateTime.UtcNow.AddHours(-2),
Modified = DateTime.UtcNow,
Description = "Description2"
}
}
};
var binary = parent.ToBinary();
var result = binary.BinaryTo<ConcreteParent>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual(6, result.Creator, "Creator should be 6");
Assert.AreEqual(parent.Created.Ticks, result.Created.Ticks,
$"Created mismatch. Expected: {parent.Created}, Got: {result.Created}");
Assert.IsNotNull(result.Items);
Assert.AreEqual(2, result.Items.Count);
Assert.AreEqual(10, result.Items[0].Id);
Assert.AreEqual(100, result.Items[0].ExtraInt, "ExtraInt should be preserved");
Assert.AreEqual(parent.Items[0].Created.Ticks, result.Items[0].Created.Ticks,
"Item Created should match");
}
/// <summary>
/// Test with list of generic parents.
/// </summary>
[TestMethod]
public void Deserialize_ListOfGenericParents_AllItemsCorrect()
{
var parents = Enumerable.Range(1, 5).Select(p => new ConcreteParent
{
Id = p,
Name = $"Parent_{p}",
Creator = p,
Created = DateTime.UtcNow.AddDays(-p),
Modified = DateTime.UtcNow,
Items = Enumerable.Range(1, 3).Select(i => new GenericItemImpl
{
Id = p * 100 + i,
Name = $"Item_{p}_{i}",
ExtraInt = p * 10 + i,
Created = DateTime.UtcNow.AddHours(-i),
Modified = DateTime.UtcNow,
Description = $"Desc_{p}_{i}"
}).ToList()
}).ToList();
var binary = parents.ToBinary();
var result = binary.BinaryTo<List<ConcreteParent>>();
Assert.IsNotNull(result);
Assert.AreEqual(5, result.Count);
for (int p = 0; p < 5; p++)
{
var original = parents[p];
var deserialized = result[p];
Assert.AreEqual(original.Id, deserialized.Id, $"Parent[{p}].Id mismatch");
Assert.AreEqual(original.Creator, deserialized.Creator, $"Parent[{p}].Creator mismatch");
Assert.AreEqual(original.Created.Ticks, deserialized.Created.Ticks,
$"Parent[{p}].Created mismatch. Expected: {original.Created}, Got: {deserialized.Created}");
Assert.IsNotNull(deserialized.Items, $"Parent[{p}].Items is null");
Assert.AreEqual(3, deserialized.Items.Count);
for (int i = 0; i < 3; i++)
{
var origItem = original.Items![i];
var deserItem = deserialized.Items[i];
Assert.AreEqual(origItem.Id, deserItem.Id, $"Parent[{p}].Items[{i}].Id mismatch");
Assert.AreEqual(origItem.ExtraInt, deserItem.ExtraInt,
$"Parent[{p}].Items[{i}].ExtraInt mismatch");
Assert.AreEqual(origItem.Created.Ticks, deserItem.Created.Ticks,
$"Parent[{p}].Items[{i}].Created mismatch");
}
}
}
/// <summary>
/// Diagnostic: Check what PropertyType the reflection returns for generic type parameter.
/// </summary>
[TestMethod]
public void Diagnostic_GenericProperty_ReflectionType()
{
var parentType = typeof(ConcreteParent);
var itemsProp = parentType.GetProperty("Items");
Assert.IsNotNull(itemsProp);
var propType = itemsProp.PropertyType;
Console.WriteLine($"PropertyType: {propType}");
Console.WriteLine($"PropertyType.FullName: {propType.FullName}");
Console.WriteLine($"IsGenericType: {propType.IsGenericType}");
if (propType.IsGenericType)
{
var args = propType.GetGenericArguments();
Console.WriteLine($"GenericArguments.Length: {args.Length}");
foreach (var arg in args)
{
Console.WriteLine($" GenericArgument: {arg.FullName}");
}
}
Assert.IsTrue(propType.IsGenericType);
var elementType = propType.GetGenericArguments()[0];
Assert.AreEqual(typeof(GenericItemImpl), elementType,
"Element type should be GenericItemImpl, not IGenericItem");
}
}

View File

@ -0,0 +1,370 @@
using AyCode.Core.Extensions;
using static AyCode.Core.Tests.Serialization.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for navigation property serialization issues.
///
/// CRITICAL BUG REPRODUCTION:
/// When a navigation property (like StockTakingItem.Product) is populated,
/// the serializer writes properties of the navigation target (Product),
/// but these property names were NOT registered in the metadata header!
///
/// The bug pattern:
/// 1. RegisterMetadataForType walks List&lt;StockTakingItem&gt; and registers StockTakingItem properties
/// 2. StockTakingItem has a "Product" property of type Product - this property NAME is registered
/// 3. BUT Product's own properties (Name, Description, Price, CategoryId) are NOT registered!
/// 4. When Product is NOT NULL at runtime, WriteObject writes Product's property indices
/// 5. GetPropertyNameIndex returns NEW indices that weren't in the header!
/// 6. Deserializer reads property indices that don't exist in its table ? crash/type mismatch
/// </summary>
[TestClass]
public class AcBinarySerializerNavigationPropertyTests
{
/// <summary>
/// CRITICAL REGRESSION TEST: Navigation properties causing metadata mismatch.
/// This is the EXACT production scenario:
/// - StockTakingItem.Product is populated by the database query
/// - Product's properties are serialized with wrong indices
/// - Deserializer fails with type mismatch
/// </summary>
[TestMethod]
public void Deserialize_NavigationPropertyPopulated_MetadataIncludesNestedType()
{
var parent = new ParentWithNavigatingItems
{
Id = 1,
Name = "Parent",
Creator = 6, // The exact value from production error
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = DateTime.UtcNow,
Items = new List<ItemWithNavigationProperty>
{
new()
{
Id = 10,
ParentId = 1,
ProductId = 100,
IsMeasured = true,
Quantity = 50,
Created = DateTime.UtcNow.AddHours(-1),
Modified = DateTime.UtcNow,
// Navigation property IS populated - this is the key!
Product = new ProductEntity
{
Id = 100,
Name = "TestProduct",
Description = "Product description with long text",
Price = 99.99,
CategoryId = 5,
Created = DateTime.UtcNow.AddDays(-30)
}
}
}
};
var binary = parent.ToBinary();
var result = binary.BinaryTo<ParentWithNavigatingItems>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual(6, result.Creator, "Creator should be 6");
Assert.AreEqual(parent.Created.Ticks, result.Created.Ticks,
$"Created mismatch. Expected: {parent.Created}, Got: {result.Created}");
Assert.IsNotNull(result.Items);
Assert.AreEqual(1, result.Items.Count);
var item = result.Items[0];
Assert.AreEqual(10, item.Id);
Assert.AreEqual(100, item.ProductId);
// Navigation property should be deserialized correctly
Assert.IsNotNull(item.Product, "Product navigation property should not be null");
Assert.AreEqual(100, item.Product.Id);
Assert.AreEqual("TestProduct", item.Product.Name);
Assert.AreEqual(5, item.Product.CategoryId);
}
/// <summary>
/// Test with multiple items, some with Product populated, some without.
/// This creates a mixed scenario where some items have navigation properties.
/// </summary>
[TestMethod]
public void Deserialize_MixedNavigationProperties_AllItemsCorrect()
{
var parent = new ParentWithNavigatingItems
{
Id = 1,
Name = "Parent",
Creator = 6,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Items = Enumerable.Range(1, 5).Select(i => new ItemWithNavigationProperty
{
Id = i * 10,
ParentId = 1,
ProductId = 100 + i,
IsMeasured = i % 2 == 0,
Quantity = i * 10,
Created = DateTime.UtcNow.AddHours(-i),
Modified = DateTime.UtcNow,
// Only populate Product for even items
Product = i % 2 == 0 ? new ProductEntity
{
Id = 100 + i,
Name = $"Product_{i}",
Description = $"Description for product {i}",
Price = i * 10.5,
CategoryId = i % 3,
Created = DateTime.UtcNow.AddDays(-i)
} : null
}).ToList()
};
var binary = parent.ToBinary();
var result = binary.BinaryTo<ParentWithNavigatingItems>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual(6, result.Creator);
Assert.IsNotNull(result.Items);
Assert.AreEqual(5, result.Items.Count);
for (int i = 1; i <= 5; i++)
{
var item = result.Items[i - 1];
Assert.AreEqual(i * 10, item.Id, $"Item {i} Id mismatch");
if (i % 2 == 0)
{
Assert.IsNotNull(item.Product, $"Item {i} should have Product");
Assert.AreEqual($"Product_{i}", item.Product.Name);
}
else
{
Assert.IsNull(item.Product, $"Item {i} should not have Product");
}
}
}
/// <summary>
/// Test with list of parents, each with items with navigation properties.
/// This is the exact production scenario - multiple StockTaking entities
/// each with StockTakingItems that have Product navigation properties.
/// </summary>
[TestMethod]
public void Deserialize_ListOfParentsWithNavigationProperties_AllCorrect()
{
var parents = Enumerable.Range(1, 3).Select(p => new ParentWithNavigatingItems
{
Id = p,
Name = $"Parent_{p}",
Creator = p,
Created = DateTime.UtcNow.AddDays(-p),
Modified = DateTime.UtcNow,
Items = Enumerable.Range(1, 2).Select(i => new ItemWithNavigationProperty
{
Id = p * 100 + i,
ParentId = p,
ProductId = 1000 + i,
IsMeasured = true,
Quantity = 10 * i,
Created = DateTime.UtcNow.AddHours(-i),
Modified = DateTime.UtcNow,
Product = new ProductEntity
{
Id = 1000 + i,
Name = $"Product_{p}_{i}",
Description = $"Description {p}_{i}",
Price = (p * 10) + (i * 1.5),
CategoryId = i % 3,
Created = DateTime.UtcNow.AddDays(-10)
}
}).ToList()
}).ToList();
var binary = parents.ToBinary();
var result = binary.BinaryTo<List<ParentWithNavigatingItems>>();
Assert.IsNotNull(result);
Assert.AreEqual(3, result.Count);
for (int p = 0; p < 3; p++)
{
var parent = result[p];
Assert.AreEqual(p + 1, parent.Id, $"Parent[{p}].Id mismatch");
Assert.AreEqual(p + 1, parent.Creator, $"Parent[{p}].Creator mismatch");
Assert.IsNotNull(parent.Items);
Assert.AreEqual(2, parent.Items.Count);
for (int i = 0; i < 2; i++)
{
var item = parent.Items[i];
Assert.IsNotNull(item.Product, $"Parent[{p}].Items[{i}].Product should not be null");
Assert.AreEqual($"Product_{p + 1}_{i + 1}", item.Product.Name);
}
}
}
/// <summary>
/// Test deeply nested navigation properties.
/// Product has a Category, Category has a Parent, etc.
/// </summary>
[TestMethod]
public void Deserialize_DeeplyNestedNavigationProperties_AllCorrect()
{
// This tests that the serializer correctly handles navigation properties
// even when they are deeply nested (Product -> Category -> Parent)
var parent = new ParentWithNavigatingItems
{
Id = 1,
Name = "Parent",
Creator = 6,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Items = new List<ItemWithNavigationProperty>
{
new()
{
Id = 10,
ParentId = 1,
ProductId = 100,
IsMeasured = true,
Quantity = 50,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Product = new ProductEntity
{
Id = 100,
Name = "ProductWithDetails",
Description = "Very long description that should be interned",
Price = 123.45,
CategoryId = 10,
Created = DateTime.UtcNow.AddMonths(-6)
}
},
new()
{
Id = 20,
ParentId = 1,
ProductId = 200,
IsMeasured = false,
Quantity = 25,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Product = new ProductEntity
{
Id = 200,
Name = "AnotherProduct",
Description = "Another description",
Price = 67.89,
CategoryId = 20,
Created = DateTime.UtcNow.AddMonths(-3)
}
}
}
};
var binary = parent.ToBinary();
// Log binary size for debugging
Console.WriteLine($"Binary size: {binary.Length} bytes");
var result = binary.BinaryTo<ParentWithNavigatingItems>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual(6, result.Creator);
Assert.IsNotNull(result.Items);
Assert.AreEqual(2, result.Items.Count);
// First item
Assert.AreEqual(10, result.Items[0].Id);
Assert.IsNotNull(result.Items[0].Product);
Assert.AreEqual("ProductWithDetails", result.Items[0].Product.Name);
Assert.AreEqual(123.45, result.Items[0].Product.Price);
// Second item
Assert.AreEqual(20, result.Items[1].Id);
Assert.IsNotNull(result.Items[1].Product);
Assert.AreEqual("AnotherProduct", result.Items[1].Product.Name);
Assert.AreEqual(67.89, result.Items[1].Product.Price);
}
/// <summary>
/// Test with same Product instance referenced multiple times.
/// This tests the reference handling with navigation properties.
/// </summary>
[TestMethod]
public void Deserialize_SharedNavigationProperty_ReferencesPreserved()
{
// Create a shared Product that is referenced by multiple items
var sharedProduct = new ProductEntity
{
Id = 999,
Name = "SharedProduct",
Description = "This product is shared across items",
Price = 50.00,
CategoryId = 1,
Created = DateTime.UtcNow.AddYears(-1)
};
var parent = new ParentWithNavigatingItems
{
Id = 1,
Name = "Parent",
Creator = 6,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Items = new List<ItemWithNavigationProperty>
{
new()
{
Id = 10,
ParentId = 1,
ProductId = 999,
IsMeasured = true,
Quantity = 50,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Product = sharedProduct // Same reference
},
new()
{
Id = 20,
ParentId = 1,
ProductId = 999,
IsMeasured = false,
Quantity = 75,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Product = sharedProduct // Same reference
}
}
};
var binary = parent.ToBinary();
var result = binary.BinaryTo<ParentWithNavigatingItems>();
Assert.IsNotNull(result);
Assert.IsNotNull(result.Items);
Assert.AreEqual(2, result.Items.Count);
// Both items should have the same Product values
Assert.IsNotNull(result.Items[0].Product);
Assert.IsNotNull(result.Items[1].Product);
Assert.AreEqual(999, result.Items[0].Product.Id);
Assert.AreEqual(999, result.Items[1].Product.Id);
Assert.AreEqual("SharedProduct", result.Items[0].Product.Name);
Assert.AreEqual("SharedProduct", result.Items[1].Product.Name);
// With reference handling, they should be the same instance
// (This depends on UseReferenceHandling being enabled)
Console.WriteLine($"Same instance: {ReferenceEquals(result.Items[0].Product, result.Items[1].Product)}");
}
}

View File

@ -0,0 +1,237 @@
using AyCode.Core.Extensions;
using static AyCode.Core.Tests.Serialization.AcSerializerModels;
using static AyCode.Core.Tests.Serialization.AcSerializerTestHelper;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for nullable value type serialization.
/// </summary>
[TestClass]
public class AcBinarySerializerNullableTests
{
[TestMethod]
public void Deserialize_NullableIntProperty_WithValue_RoundTrip()
{
var obj = new TestClassWithNullableProperties { Id = 42, NullableInt = 123, NullableIntNull = null };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(42, result.Id);
Assert.AreEqual(123, result.NullableInt);
Assert.IsNull(result.NullableIntNull);
}
[TestMethod]
public void Deserialize_NullableDoubleProperty_WithValue_RoundTrip()
{
var obj = new TestClassWithNullableProperties { Id = 1, NullableDouble = 3.14159, NullableDoubleNull = null };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(3.14159, result.NullableDouble);
Assert.IsNull(result.NullableDoubleNull);
}
[TestMethod]
public void Deserialize_NullableDateTimeProperty_WithValue_RoundTrip()
{
var testDate = new DateTime(2024, 12, 25, 10, 30, 45, DateTimeKind.Utc);
var obj = new TestClassWithNullableProperties { Id = 1, NullableDateTime = testDate, NullableDateTimeNull = null };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(testDate, result.NullableDateTime);
Assert.IsNull(result.NullableDateTimeNull);
}
[TestMethod]
public void Deserialize_NullableGuidProperty_WithValue_RoundTrip()
{
var testGuid = Guid.NewGuid();
var obj = new TestClassWithNullableProperties { Id = 1, NullableGuid = testGuid, NullableGuidNull = null };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(testGuid, result.NullableGuid);
Assert.IsNull(result.NullableGuidNull);
}
[TestMethod]
public void Deserialize_NullableDecimalProperty_WithValue_RoundTrip()
{
var obj = new TestClassWithNullableProperties { Id = 1, NullableDecimal = 123456.789m, NullableDecimalNull = null };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(123456.789m, result.NullableDecimal);
Assert.IsNull(result.NullableDecimalNull);
}
[TestMethod]
public void Deserialize_NullableBoolProperty_WithValue_RoundTrip()
{
var obj = new TestClassWithNullableProperties { Id = 1, NullableBool = true, NullableBoolNull = null };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(true, result.NullableBool);
Assert.IsNull(result.NullableBoolNull);
}
[TestMethod]
public void Deserialize_NullableLongProperty_WithValue_RoundTrip()
{
var obj = new TestClassWithNullableProperties { Id = 1, NullableLong = 9876543210L, NullableLongNull = null };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(9876543210L, result.NullableLong);
Assert.IsNull(result.NullableLongNull);
}
[TestMethod]
public void Deserialize_AllNullablePropertiesWithValues_RoundTrip()
{
var testDate = new DateTime(2024, 6, 15, 14, 30, 0, DateTimeKind.Utc);
var testGuid = Guid.Parse("12345678-1234-1234-1234-123456789abc");
var obj = new TestClassWithNullableProperties
{
Id = 999,
NullableInt = int.MaxValue,
NullableIntNull = 42,
NullableLong = long.MaxValue,
NullableLongNull = 100L,
NullableDouble = double.MaxValue,
NullableDoubleNull = 2.5,
NullableDecimal = decimal.MaxValue,
NullableDecimalNull = 1.1m,
NullableDateTime = testDate,
NullableDateTimeNull = DateTime.UtcNow,
NullableGuid = testGuid,
NullableGuidNull = Guid.NewGuid(),
NullableBool = false,
NullableBoolNull = true
};
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(obj.Id, result.Id);
Assert.AreEqual(obj.NullableInt, result.NullableInt);
Assert.AreEqual(obj.NullableIntNull, result.NullableIntNull);
Assert.AreEqual(obj.NullableLong, result.NullableLong);
Assert.AreEqual(obj.NullableLongNull, result.NullableLongNull);
Assert.AreEqual(obj.NullableDouble, result.NullableDouble);
Assert.AreEqual(obj.NullableDecimalNull, result.NullableDecimalNull);
Assert.AreEqual(obj.NullableDateTime, result.NullableDateTime);
Assert.AreEqual(obj.NullableGuid, result.NullableGuid);
Assert.AreEqual(obj.NullableBool, result.NullableBool);
Assert.AreEqual(obj.NullableBoolNull, result.NullableBoolNull);
}
[TestMethod]
public void Deserialize_ObjectWithNestedNullableProperties_RoundTrip()
{
var obj = new TestParentWithNullableChild
{
Id = 1,
Name = "Parent",
Child = new TestClassWithNullableProperties
{
Id = 2,
NullableInt = 100,
NullableDouble = 5.5,
NullableDateTime = DateTime.UtcNow,
NullableGuid = Guid.NewGuid()
}
};
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestParentWithNullableChild>();
Assert.IsNotNull(result);
Assert.AreEqual(obj.Id, result.Id);
Assert.AreEqual(obj.Name, result.Name);
Assert.IsNotNull(result.Child);
Assert.AreEqual(obj.Child.NullableInt, result.Child.NullableInt);
Assert.AreEqual(obj.Child.NullableDouble, result.Child.NullableDouble);
}
[TestMethod]
public void Deserialize_ListOfObjectsWithNullableProperties_RoundTrip()
{
var items = CreateNullablePropertyItems(10);
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestClassWithNullableProperties>>();
Assert.IsNotNull(result);
Assert.AreEqual(10, result.Count);
for (int i = 0; i < 10; i++)
{
var original = items[i];
var deserialized = result[i];
Assert.AreEqual(original.Id, deserialized.Id, $"Id mismatch at index {i}");
Assert.AreEqual(original.NullableInt, deserialized.NullableInt, $"NullableInt mismatch at index {i}");
Assert.AreEqual(original.NullableDouble, deserialized.NullableDouble, $"NullableDouble mismatch at index {i}");
Assert.AreEqual(original.NullableGuid, deserialized.NullableGuid, $"NullableGuid mismatch at index {i}");
}
}
[TestMethod]
public void Deserialize_StockTakingLikeHierarchy_WithNullableProperties_RoundTrip()
{
var stockTaking = CreateStockTaking(2, 2);
var binary = stockTaking.ToBinary();
var result = binary.BinaryTo<TestStockTaking>();
Assert.IsNotNull(result);
Assert.AreEqual(stockTaking.Id, result.Id);
Assert.AreEqual(stockTaking.IsClosed, result.IsClosed);
Assert.AreEqual(stockTaking.Creator, result.Creator);
Assert.IsNotNull(result.StockTakingItems);
Assert.AreEqual(2, result.StockTakingItems.Count);
var item0 = result.StockTakingItems[0];
Assert.IsNotNull(item0.StockTakingItemPallets);
Assert.AreEqual(2, item0.StockTakingItemPallets.Count);
}
[TestMethod]
public void Deserialize_ListOfStockTakingLikeEntities_RoundTrip()
{
var stockTakings = CreateStockTakingList(2, 1, 1);
var binary = stockTakings.ToBinary();
var result = binary.BinaryTo<List<TestStockTaking>>();
Assert.IsNotNull(result);
Assert.AreEqual(2, result.Count);
Assert.AreEqual(1, result[0].Id);
Assert.IsNotNull(result[0].StockTakingItems);
Assert.AreEqual(1, result[0].StockTakingItems.Count);
Assert.AreEqual(2, result[1].Id);
Assert.IsNotNull(result[1].StockTakingItems);
}
}

View File

@ -0,0 +1,130 @@
using AyCode.Core.Extensions;
using static AyCode.Core.Tests.Serialization.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for object serialization including nested objects, lists, and dictionaries.
/// </summary>
[TestClass]
public class AcBinarySerializerObjectTests
{
[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 = ["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]);
}
}
[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);
Assert.AreEqual(20, target.Child.Id);
Assert.AreEqual("UpdatedChild", target.Child.Name);
}
}

View File

@ -0,0 +1,289 @@
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Binaries;
using static AyCode.Core.Tests.Serialization.AcSerializerModels;
using static AyCode.Core.Tests.Serialization.AcSerializerTestHelper;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for string interning functionality.
/// </summary>
[TestClass]
public class AcBinarySerializerStringInterningTests
{
[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 });
Assert.IsTrue(binaryWithInterning.Length < binaryWithoutInterning.Length,
$"With interning: {binaryWithInterning.Length}, Without: {binaryWithoutInterning.Length}");
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.
/// </summary>
[TestMethod]
public void StringInterning_PropertyNames_MustBeRegisteredDuringDeserialization()
{
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()
{
var items = Enumerable.Range(0, 20).Select(i => new TestClassWithMixedStrings
{
Id = i,
ShortName = $"A{i % 3}",
LongName = $"LongName_{i % 5}",
Description = $"Description_value_{i % 7}",
Tag = i % 2 == 0 ? "AB" : "XY"
}).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()
{
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()
{
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()
{
var items = new List<TestClassWithNameValue>();
for (int i = 0; i < 30; i++)
{
items.Add(new TestClassWithNameValue
{
Name = $"UniqueName_{i:D4}",
Value = $"UniqueValue_{i:D4}"
});
}
for (int i = 0; i < 20; i++)
{
items.Add(new TestClassWithNameValue
{
Name = $"UniqueName_{i % 10:D4}",
Value = $"UniqueValue_{(i + 10) % 30:D4}"
});
}
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestClassWithNameValue>>();
Assert.IsNotNull(result);
Assert.AreEqual(50, result.Count);
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}");
}
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()
{
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)
{
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()
{
var customers = CreateCustomerLikeItems(25);
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()
{
const int itemCount = 150;
var items = Enumerable.Range(0, itemCount).Select(i => new TestHighReuseDto
{
Id = i,
CategoryCode = $"CAT_{i % 10:D2}",
StatusCode = $"STATUS_{i % 5:D2}",
TypeCode = $"TYPE_{i % 3:D2}",
PriorityCode = i % 2 == 0 ? "PRIORITY_HIGH" : "PRIORITY_LOW",
UniqueField = $"UNIQUE_{i:D4}"
}).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");
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}");
}
}
}

View File

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

View File

@ -0,0 +1,633 @@
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Test models for binary serializer tests.
/// </summary>
public static class AcSerializerModels
{
public class TestSimpleClass
{
public int Id { get; set; }
public string Name { get; set; } = "";
public double Value { get; set; }
public bool IsActive { get; set; }
}
public class TestNestedClass
{
public int Id { get; set; }
public string Name { get; set; } = "";
public TestSimpleClass? Child { get; set; }
}
public class TestClassWithList
{
public int Id { get; set; }
public List<string> Items { get; set; } = new();
}
public class TestClassWithRepeatedStrings
{
public string Field1 { get; set; } = "";
public string Field2 { get; set; } = "";
public string Field3 { get; set; } = "";
public string Field4 { get; set; } = "";
}
public class TestClassWithLongPropertyNames
{
public string FirstProperty { get; set; } = "";
public string SecondProperty { get; set; } = "";
public string ThirdProperty { get; set; } = "";
public string FourthProperty { get; set; } = "";
}
public class TestClassWithMixedStrings
{
public int Id { get; set; }
public string ShortName { get; set; } = "";
public string LongName { get; set; } = "";
public string Description { get; set; } = "";
public string Tag { get; set; } = "";
}
public class TestNestedStructure
{
public string RootName { get; set; } = "";
public List<TestLevel1> Level1Items { get; set; } = new();
}
public class TestLevel1
{
public string Level1Name { get; set; } = "";
public List<TestLevel2> Level2Items { get; set; } = new();
}
public class TestLevel2
{
public string Level2Name { get; set; } = "";
public string Value { get; set; } = "";
}
public class TestClassWithRepeatedValues
{
public int Id { get; set; }
public string Status { get; set; } = "";
public string Category { get; set; } = "";
public string Priority { get; set; } = "";
}
public class TestClassWithNameValue
{
public string Name { get; set; } = "";
public string Value { get; set; } = "";
}
public class TestClassWithNullableStrings
{
public int Id { get; set; }
public string RequiredName { get; set; } = "";
public string? OptionalName { get; set; }
public string? Description { get; set; }
}
public 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<TestAttribute> Attributes { get; set; } = new();
}
public class TestAttribute
{
public string Key { get; set; } = "";
public string Value { get; set; } = "";
}
public 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; } = "";
}
public class TestClassWithNullableProperties
{
public int Id { get; set; }
public int? NullableInt { get; set; }
public int? NullableIntNull { get; set; }
public long? NullableLong { get; set; }
public long? NullableLongNull { get; set; }
public double? NullableDouble { get; set; }
public double? NullableDoubleNull { get; set; }
public decimal? NullableDecimal { get; set; }
public decimal? NullableDecimalNull { get; set; }
public DateTime? NullableDateTime { get; set; }
public DateTime? NullableDateTimeNull { get; set; }
public Guid? NullableGuid { get; set; }
public Guid? NullableGuidNull { get; set; }
public bool? NullableBool { get; set; }
public bool? NullableBoolNull { get; set; }
}
public class TestParentWithNullableChild
{
public int Id { get; set; }
public string Name { get; set; } = "";
public TestClassWithNullableProperties? Child { get; set; }
}
/// <summary>
/// Test model mimicking StockTaking entity structure.
/// /// </summary>
public class TestStockTaking
{
public int Id { get; set; }
public DateTime StartDateTime { get; set; }
public bool IsClosed { get; set; }
public int Creator { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
public List<TestStockTakingItem>? StockTakingItems { get; set; }
}
/// <summary>
/// Test model mimicking StockTakingItem entity structure.
/// /// </summary>
public class TestStockTakingItem
{
public int Id { get; set; }
public int StockTakingId { get; set; }
public int ProductId { get; set; }
public bool IsMeasured { get; set; }
public int OriginalStockQuantity { get; set; }
public int MeasuredStockQuantity { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
public List<TestStockTakingItemPallet>? StockTakingItemPallets { get; set; }
}
/// <summary>
/// Test model mimicking StockTakingItemPallet/MeasuringItemPalletBase entity structure.
/// Contains nullable int properties (CreatorId, ModifierId) that caused the production bug.
/// </summary>
public class TestStockTakingItemPallet
{
public int Id { get; set; }
public int StockTakingItemId { get; set; }
public int TrayQuantity { get; set; }
public double TareWeight { get; set; }
public double PalletWeight { get; set; }
public double GrossWeight { get; set; }
public bool IsMeasured { get; set; }
public int? CreatorId { get; set; }
public int? ModifierId { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
}
public class TestEntityWithDateTimeAndInt
{
public int Id { get; set; }
public int IntValue { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
public int StatusCode { get; set; }
public string Name { get; set; } = "";
}
public class TestEntityWithManyIntsBeforeDateTime
{
public int Id { get; set; }
public int Value1 { get; set; }
public int Value2 { get; set; }
public int Value3 { get; set; }
public int Value4 { get; set; }
public int Value5 { get; set; }
public DateTime FirstDateTime { get; set; }
public DateTime SecondDateTime { get; set; }
public int FinalValue { get; set; }
}
public class TestParentEntityWithDateTimeChild
{
public int ParentId { get; set; }
public string ParentName { get; set; } = "";
public TestEntityWithDateTimeAndInt? Child { get; set; }
}
public class TestParentWithDateTimeItemCollection
{
public int Id { get; set; }
public string Name { get; set; } = "";
public DateTime Created { get; set; }
public List<TestEntityWithDateTimeAndInt>? Items { get; set; }
}
/// <summary>
/// Base class to simulate inheritance hierarchy like MgEntityBase.
/// Id property is in base class, so reflection may return it last.
/// </summary>
public abstract class TestEntityBase
{
public int Id { get; set; }
}
/// <summary>
/// Simulates MgStockTaking structure with inheritance.
/// Properties are ordered: StartDateTime, IsClosed, StockTakingItems, Creator, Created, Modified
/// Id comes from base class and may appear last in reflection.
/// </summary>
public class TestStockTakingWithInheritance : TestEntityBase
{
public DateTime StartDateTime { get; set; }
public bool IsClosed { get; set; }
public List<TestStockTakingItemWithInheritance>? StockTakingItems { get; set; }
public int Creator { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
}
/// <summary>
/// Simulates StockTakingItem with inheritance.
/// </summary>
public class TestStockTakingItemWithInheritance : TestEntityBase
{
public int StockTakingId { get; set; }
public int ProductId { get; set; }
public bool IsMeasured { get; set; }
public int OriginalStockQuantity { get; set; }
public int MeasuredStockQuantity { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
public List<TestStockTakingItemPalletWithInheritance>? StockTakingItemPallets { get; set; }
}
/// <summary>
/// Simulates MeasuringItemPalletBase with inheritance and ForeignKey pattern.
/// </summary>
public class TestStockTakingItemPalletWithInheritance : TestEntityBase
{
public int StockTakingItemId { get; set; }
public int TrayQuantity { get; set; }
public double TareWeight { get; set; }
public double PalletWeight { get; set; }
public double GrossWeight { get; set; }
public bool IsMeasured { get; set; }
public int? CreatorId { get; set; }
public int? ModifierId { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
}
#region Schema Mismatch Tests
/// <summary>
/// Server-side model with extra properties that client doesn't know about.
/// </summary>
public class ServerStockTaking
{
public int Id { get; set; }
public DateTime StartDateTime { get; set; }
public bool IsClosed { get; set; }
public int Creator { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
public List<ServerStockTakingItem>? StockTakingItems { get; set; }
// Extra properties that client doesn't have
public string? ExtraServerProperty1 { get; set; }
public int ExtraServerProperty2 { get; set; }
}
/// <summary>
/// Server-side item with Product navigation property.
/// </summary>
public class ServerStockTakingItem
{
public int Id { get; set; }
public int StockTakingId { get; set; }
public int ProductId { get; set; }
public bool IsMeasured { get; set; }
public int OriginalStockQuantity { get; set; }
public int MeasuredStockQuantity { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
// Navigation property - this gets serialized on server!
public ServerProductDto? Product { get; set; }
public List<ServerStockTakingItemPallet>? StockTakingItemPallets { get; set; }
}
/// <summary>
/// Server-side product DTO.
/// </summary>
public class ServerProductDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public double Price { get; set; }
public int CategoryId { get; set; }
}
/// <summary>
/// Server-side pallet with all properties.
/// </summary>
public class ServerStockTakingItemPallet
{
public int Id { get; set; }
public int StockTakingItemId { get; set; }
public int TrayQuantity { get; set; }
public double TareWeight { get; set; }
public double PalletWeight { get; set; }
public double GrossWeight { get; set; }
public bool IsMeasured { get; set; }
public int? CreatorId { get; set; }
public int? ModifierId { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
}
/// <summary>
/// Client-side model - MISSING some properties that server sends.
/// </summary>
public class ClientStockTaking
{
public int Id { get; set; }
public DateTime StartDateTime { get; set; }
public bool IsClosed { get; set; }
public int Creator { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
public List<ClientStockTakingItem>? StockTakingItems { get; set; }
// Note: ExtraServerProperty1 and ExtraServerProperty2 are MISSING
}
/// <summary>
/// Client-side item - MISSING Product navigation property.
/// </summary>
public class ClientStockTakingItem
{
public int Id { get; set; }
public int StockTakingId { get; set; }
public int ProductId { get; set; }
public bool IsMeasured { get; set; }
public int OriginalStockQuantity { get; set; }
public int MeasuredStockQuantity { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
// Note: Product property is MISSING - server sends it, client skips it
public List<ClientStockTakingItemPallet>? StockTakingItemPallets { get; set; }
}
/// <summary>
/// Client-side pallet.
/// </summary>
public class ClientStockTakingItemPallet
{
public int Id { get; set; }
public int StockTakingItemId { get; set; }
public int TrayQuantity { get; set; }
public double TareWeight { get; set; }
public double PalletWeight { get; set; }
public double GrossWeight { get; set; }
public bool IsMeasured { get; set; }
public int? CreatorId { get; set; }
public int? ModifierId { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
}
#endregion
#region Circular Reference Test Models
/// <summary>
/// Parent entity with circular reference to child.
/// </summary>
public class CircularParent
{
public int Id { get; set; }
public string Name { get; set; } = "";
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
public int Creator { get; set; }
public List<CircularChild>? Children { get; set; }
}
/// <summary>
/// Child entity with back-reference to parent (circular).
/// </summary>
public class CircularChild
{
public int Id { get; set; }
public int ParentId { get; set; }
public string Name { get; set; } = "";
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
// Back-reference to parent - creates circular reference!
public CircularParent? Parent { get; set; }
public List<CircularGrandChild>? GrandChildren { get; set; }
}
/// <summary>
/// Grandchild with back-reference.
/// </summary>
public class CircularGrandChild
{
public int Id { get; set; }
public int ChildId { get; set; }
public int? CreatorId { get; set; }
public int? ModifierId { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
// Back-reference
public CircularChild? Child { get; set; }
}
#endregion
#region Generic Type Parameter Test Models
/// <summary>
/// Interface with fewer properties than implementation.
/// </summary>
public interface IGenericItem
{
int Id { get; set; }
string Name { get; set; }
}
/// <summary>
/// Implementation with MORE properties than interface.
/// This is the exact pattern of StockTakingItem.
/// </summary>
public class GenericItemImpl : IGenericItem
{
public int Id { get; set; }
public string Name { get; set; } = "";
// Extra properties NOT in interface
public int ExtraInt { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
public string Description { get; set; } = "";
}
/// <summary>
/// Parent class with generic type parameter for items.
/// Similar to MgStockTaking&lt;TStockTakingItem&gt;.
/// </summary>
public class GenericParent<TItem> where TItem : class, IGenericItem
{
public int Id { get; set; }
public string Name { get; set; } = "";
public int Creator { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
public List<TItem>? Items { get; set; }
}
/// <summary>
/// Concrete implementation.
/// Similar to StockTaking : MgStockTaking&lt;StockTakingItem&gt;.
/// </summary>
public class ConcreteParent : GenericParent<GenericItemImpl>
{
}
#endregion
#region Navigation Property Test Models
/// <summary>
/// Product entity that is NOT in the parent's generic type hierarchy.
/// </summary>
public class ProductEntity
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public double Price { get; set; }
public int CategoryId { get; set; }
public DateTime Created { get; set; }
}
/// <summary>
/// Item entity with navigation property to Product.
/// This simulates StockTakingItem.Product.
/// </summary>
public class ItemWithNavigationProperty
{
public int Id { get; set; }
public int ParentId { get; set; }
public int ProductId { get; set; }
public bool IsMeasured { get; set; }
public int Quantity { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
// Navigation property - NOT in parent's generic type!
// When populated, its properties need to be in metadata table too!
public ProductEntity? Product { get; set; }
}
/// <summary>
/// Parent entity with items that have navigation properties.
/// This simulates StockTaking with StockTakingItems that have Product.
/// </summary>
public class ParentWithNavigatingItems
{
public int Id { get; set; }
public string Name { get; set; } = "";
public int Creator { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
public List<ItemWithNavigationProperty>? Items { get; set; }
}
#endregion
#region Property Order Test Models
/// <summary>
/// Simulates NopCommerce BaseEntity.
/// </summary>
public abstract class NopBaseEntity
{
public int Id { get; set; }
}
/// <summary>
/// Simulates MgEntityBase : BaseEntity.
/// </summary>
public abstract class SimMgEntityBase : NopBaseEntity
{
public override string ToString() => $"{GetType().Name}; Id: {Id}";
}
/// <summary>
/// Simulates MgStockTaking with exact property order from production.
/// CRITICAL: Property order in code may differ from reflection order!
/// </summary>
public abstract class SimMgStockTaking<TStockTakingItem> : SimMgEntityBase
where TStockTakingItem : class
{
public DateTime StartDateTime { get; set; }
public bool IsClosed { get; set; }
public List<TStockTakingItem>? StockTakingItems { get; set; }
public int Creator { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
}
/// <summary>
/// Simulates the concrete StockTaking class.
/// </summary>
public class SimStockTaking : SimMgStockTaking<SimStockTakingItem>
{
}
/// <summary>
/// Simulates StockTakingItem with navigation properties.
/// </summary>
public class SimStockTakingItem : SimMgEntityBase
{
public int StockTakingId { get; set; }
public int ProductId { get; set; }
public bool IsMeasured { get; set; }
public int OriginalStockQuantity { get; set; }
public int MeasuredStockQuantity { get; set; }
// Navigation property back to parent (circular!)
public SimStockTaking? StockTaking { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
}
#endregion
}

View File

@ -0,0 +1,251 @@
using static AyCode.Core.Tests.Serialization.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Helper methods for creating test data in serializer tests.
/// </summary>
public static class AcSerializerTestHelper
{
public static List<TestClassWithLongPropertyNames> CreateLongPropertyNameItems(int count)
{
return Enumerable.Range(0, count).Select(i => new TestClassWithLongPropertyNames
{
FirstProperty = $"Value1_{i}",
SecondProperty = $"Value2_{i}",
ThirdProperty = $"Value3_{i}",
FourthProperty = $"Value4_{i}"
}).ToList();
}
public static List<TestClassWithMixedStrings> CreateMixedStringItems(int count)
{
return Enumerable.Range(0, count).Select(i => new TestClassWithMixedStrings
{
Id = i,
ShortName = $"A{i % 3}",
LongName = $"LongName_{i % 5}",
Description = $"Description_value_{i % 7}",
Tag = i % 2 == 0 ? "AB" : "XY"
}).ToList();
}
public static TestNestedStructure CreateNestedStructure(int level1Count, int level2Count)
{
return new TestNestedStructure
{
RootName = "RootObject",
Level1Items = Enumerable.Range(0, level1Count).Select(i => new TestLevel1
{
Level1Name = $"Level1_{i}",
Level2Items = Enumerable.Range(0, level2Count).Select(j => new TestLevel2
{
Level2Name = $"Level2_{i}_{j}",
Value = $"Value_{i * level2Count + j}"
}).ToList()
}).ToList()
};
}
public static List<TestClassWithRepeatedValues> CreateRepeatedValueItems(int count)
{
return Enumerable.Range(0, count).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();
}
public static List<TestClassWithNameValue> CreateNameValueItems(int uniqueCount, int reuseCount)
{
var items = new List<TestClassWithNameValue>();
for (int i = 0; i < uniqueCount; i++)
{
items.Add(new TestClassWithNameValue
{
Name = $"UniqueName_{i:D4}",
Value = $"UniqueValue_{i:D4}"
});
}
for (int i = 0; i < reuseCount; i++)
{
items.Add(new TestClassWithNameValue
{
Name = $"UniqueName_{i % 10:D4}",
Value = $"UniqueValue_{(i + 10) % uniqueCount:D4}"
});
}
return items;
}
public static List<TestClassWithNullableStrings> CreateNullableStringItems(int count)
{
return Enumerable.Range(0, count).Select(i => 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
}).ToList();
}
public static List<TestCustomerLikeDto> CreateCustomerLikeItems(int count)
{
return Enumerable.Range(0, count).Select(i => new TestCustomerLikeDto
{
Id = i,
FirstName = $"FirstName_{i % 10}",
LastName = $"LastName_{i % 8}",
Email = $"user{i}@example.com",
Company = $"Company_{i % 5}",
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() { 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();
}
public static List<TestHighReuseDto> CreateHighReuseItems(int count)
{
return Enumerable.Range(0, count).Select(i => new TestHighReuseDto
{
Id = i,
CategoryCode = $"CAT_{i % 10:D2}",
StatusCode = $"STATUS_{i % 5:D2}",
TypeCode = $"TYPE_{i % 3:D2}",
PriorityCode = i % 2 == 0 ? "PRIORITY_HIGH" : "PRIORITY_LOW",
UniqueField = $"UNIQUE_{i:D4}"
}).ToList();
}
public static List<TestClassWithNullableProperties> CreateNullablePropertyItems(int count)
{
return Enumerable.Range(1, count).Select(i => new TestClassWithNullableProperties
{
Id = i,
NullableInt = i % 3 == 0 ? null : i * 10,
NullableDouble = i % 2 == 0 ? null : i * 1.5,
NullableDateTime = i % 4 == 0 ? null : DateTime.UtcNow.AddDays(-i),
NullableGuid = i % 5 == 0 ? null : Guid.NewGuid()
}).ToList();
}
public static TestStockTaking CreateStockTaking(int itemCount = 2, int palletCount = 2)
{
return new TestStockTaking
{
Id = 1,
StartDateTime = DateTime.UtcNow,
IsClosed = false,
Creator = 100,
Created = DateTime.UtcNow.AddHours(-2),
Modified = DateTime.UtcNow,
StockTakingItems = Enumerable.Range(1, itemCount).Select(i => new TestStockTakingItem
{
Id = i * 10,
StockTakingId = 1,
ProductId = 500 + i,
IsMeasured = i % 2 == 0,
OriginalStockQuantity = 50 * i,
MeasuredStockQuantity = 48 * i,
Created = DateTime.UtcNow.AddHours(-i),
Modified = DateTime.UtcNow,
StockTakingItemPallets = Enumerable.Range(1, palletCount).Select(p => new TestStockTakingItemPallet
{
Id = i * 100 + p,
StockTakingItemId = i * 10,
TrayQuantity = p + 2,
TareWeight = p * 0.5,
PalletWeight = p * 10.0,
GrossWeight = p * 50.0,
IsMeasured = p % 2 == 0,
CreatorId = p % 2 == 0 ? 100 : null,
ModifierId = p % 2 == 1 ? 200 : null,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow
}).ToList()
}).ToList()
};
}
public static List<TestStockTaking> CreateStockTakingList(int count, int itemsPerStock = 1, int palletsPerItem = 1)
{
return Enumerable.Range(1, count).Select(s => new TestStockTaking
{
Id = s,
StartDateTime = DateTime.UtcNow.AddDays(-s),
IsClosed = s % 2 == 0,
Creator = s,
Created = DateTime.UtcNow.AddDays(-s),
Modified = DateTime.UtcNow,
StockTakingItems = Enumerable.Range(1, itemsPerStock).Select(i => new TestStockTakingItem
{
Id = s * 100 + i,
StockTakingId = s,
ProductId = 1000 * s + i,
IsMeasured = i % 2 == 0,
OriginalStockQuantity = 10 * i,
MeasuredStockQuantity = 10 * i,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
StockTakingItemPallets = Enumerable.Range(1, palletsPerItem).Select(p => new TestStockTakingItemPallet
{
Id = s * 1000 + i * 100 + p,
StockTakingItemId = s * 100 + i,
CreatorId = p % 2 == 0 ? s * 10 : null,
ModifierId = p % 2 == 1 ? s * 20 : null,
TrayQuantity = p,
TareWeight = p * 1.0,
PalletWeight = p * 10.0,
GrossWeight = p * 50.0,
IsMeasured = p % 2 == 0,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow
}).ToList()
}).ToList()
}).ToList();
}
public static List<TestEntityWithDateTimeAndInt> CreateDateTimeEntities(int count)
{
return Enumerable.Range(1, count).Select(i => new TestEntityWithDateTimeAndInt
{
Id = i,
IntValue = i * 10,
Created = DateTime.UtcNow.AddDays(-i),
Modified = DateTime.UtcNow.AddHours(-i),
StatusCode = i % 5,
Name = $"Entity_{i}"
}).ToList();
}
public static List<TestParentWithDateTimeItemCollection> CreateParentWithDateTimeItems(int parentCount, int itemsPerParent)
{
return Enumerable.Range(1, parentCount).Select(p => new TestParentWithDateTimeItemCollection
{
Id = p,
Name = $"Parent_{p}",
Created = DateTime.UtcNow.AddDays(-p * 10),
Items = Enumerable.Range(1, itemsPerParent).Select(i => new TestEntityWithDateTimeAndInt
{
Id = p * 100 + i,
IntValue = i * 10,
Created = DateTime.UtcNow.AddDays(-i),
Modified = DateTime.UtcNow.AddHours(-i),
StatusCode = i % 3,
Name = $"Parent{p}_Item_{i}"
}).ToList()
}).ToList();
}
}

View File

@ -0,0 +1,678 @@
using System.Diagnostics;
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Tests.TestModels;
using MessagePack;
using MessagePack.Resolvers;
namespace AyCode.Core.Tests.serialization;
[TestClass]
public class QuickBenchmark
{
private static readonly MessagePackSerializerOptions MsgPackOptions =
ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
private const int DefaultIterations = 1000;
#region Helper Methods
private static void PrintBanner(string title)
{
Console.WriteLine();
Console.WriteLine(new string('=', 78));
Console.WriteLine(title);
Console.WriteLine(new string('=', 78));
}
private static void PrintTableHeader(string title)
{
PrintBanner(title);
Console.WriteLine($"{"Metric",-25} | {"AcBinary",14} | {"MessagePack",14} | {"Ratio",14}");
Console.WriteLine(new string('-', 78));
}
private static void PrintTableRow(string metric, double acBinary, double msgPack, string unit = "ms")
{
if (msgPack > 0)
{
var ratio = acBinary / msgPack;
var ratioStr = ratio < 1 ? $"{ratio:F2}x faster" : $"{ratio:F2}x slower";
Console.WriteLine($"{metric,-25} | {acBinary,10:F2} {unit,-2} | {msgPack,10:F2} {unit,-2} | {ratioStr,14}");
}
else
{
Console.WriteLine($"{metric,-25} | {acBinary,10:F2} {unit,-2} | {"N/A",12} | {"(unique)",14}");
}
}
private static void PrintTableRowSize(string metric, int acBinary, int msgPack)
{
var ratio = msgPack == 0 ? 0 : 100.0 * acBinary / msgPack;
Console.WriteLine($"{metric,-25} | {acBinary,14:N0} | {msgPack,14:N0} | {ratio,12:F1}%");
}
private static void PrintTableFooter()
{
Console.WriteLine(new string('-', 78));
}
private static void PrintSummary(int acBinarySize, int msgPackSize, double acSerMs, double msgSerMs, double acDeserMs, double msgDeserMs)
{
PrintBanner("SUMMARY");
var sizeAdvantage = msgPackSize == 0 ? 0 : 100.0 - (100.0 * acBinarySize / msgPackSize);
if (sizeAdvantage > 0)
Console.WriteLine($"[OK] Size: AcBinary is {sizeAdvantage:F1}% smaller ({msgPackSize - acBinarySize:N0} bytes saved)");
else
Console.WriteLine($"[WARN] Size: AcBinary is {-sizeAdvantage:F1}% larger");
var serRatio = msgSerMs == 0 ? 0 : acSerMs / msgSerMs;
if (serRatio > 0 && serRatio < 1)
Console.WriteLine($"[OK] Serialize: AcBinary is {1 / serRatio:F2}x faster");
else if (serRatio > 0)
Console.WriteLine($"[WARN] Serialize: AcBinary is {serRatio:F2}x slower");
var deserRatio = msgDeserMs == 0 ? 0 : acDeserMs / msgDeserMs;
if (deserRatio > 0 && deserRatio < 1)
Console.WriteLine($"[OK] Deserialize: AcBinary is {1 / deserRatio:F2}x faster");
else if (deserRatio > 0)
Console.WriteLine($"[WARN] Deserialize: AcBinary is {deserRatio:F2}x slower");
}
private static TestOrder CreatePopulateTarget(TestOrder source)
{
var target = new TestOrder { Id = source.Id };
foreach (var item in source.Items)
{
target.Items.Add(new TestOrderItem { Id = item.Id });
}
return target;
}
#endregion
#region Basic Benchmarks
[TestMethod]
public void RunQuickBenchmark()
{
// Warmup
var order = TestDataFactory.CreateBenchmarkOrder(2, 2, 2, 3);
for (int i = 0; i < 10; i++)
{
var bytes = order.ToBinary();
var result = bytes.BinaryTo<TestOrder>();
}
// Measure serialize
const int iterations = DefaultIterations;
var sw = Stopwatch.StartNew();
byte[] serialized = null!;
for (int i = 0; i < iterations; i++)
{
serialized = order.ToBinary();
}
sw.Stop();
var serializeMs = sw.Elapsed.TotalMilliseconds;
// Measure deserialize
sw.Restart();
TestOrder? deserialized = null;
for (int i = 0; i < iterations; i++)
{
deserialized = serialized.BinaryTo<TestOrder>();
}
sw.Stop();
var deserializeMs = sw.Elapsed.TotalMilliseconds;
// JSON comparison
var jsonOptions = AcJsonSerializerOptions.WithoutReferenceHandling();
sw.Restart();
string json = null!;
for (int i = 0; i < iterations; i++)
{
json = order.ToJson(jsonOptions);
}
sw.Stop();
var jsonSerializeMs = sw.Elapsed.TotalMilliseconds;
sw.Restart();
for (int i = 0; i < iterations; i++)
{
var _ = json.JsonTo<TestOrder>(jsonOptions);
}
sw.Stop();
var jsonDeserializeMs = sw.Elapsed.TotalMilliseconds;
Console.WriteLine($"=== Quick Benchmark ({iterations} iterations) ===");
Console.WriteLine($"Binary size: {serialized.Length} bytes");
Console.WriteLine($"JSON size: {json.Length} chars ({System.Text.Encoding.UTF8.GetByteCount(json)} bytes)");
Console.WriteLine();
Console.WriteLine($"Binary Serialize: {serializeMs:F2}ms ({serializeMs / iterations:F4}ms/op)");
Console.WriteLine($"Binary Deserialize: {deserializeMs:F2}ms ({deserializeMs / iterations:F4}ms/op)");
Console.WriteLine($"JSON Serialize: {jsonSerializeMs:F2}ms ({jsonSerializeMs / iterations:F4}ms/op)");
Console.WriteLine($"JSON Deserialize: {jsonDeserializeMs:F2}ms ({jsonDeserializeMs / iterations:F4}ms/op)");
Console.WriteLine();
Console.WriteLine($"Binary vs JSON Serialize: {serializeMs / jsonSerializeMs:F2}x");
Console.WriteLine($"Binary vs JSON Deserialize: {deserializeMs / jsonDeserializeMs:F2}x");
Console.WriteLine($"Size ratio: {100.0 * serialized.Length / System.Text.Encoding.UTF8.GetByteCount(json):F1}%");
Assert.IsNotNull(deserialized);
Assert.AreEqual(order.Id, deserialized.Id);
}
[TestMethod]
public void RunStringInterningBenchmark()
{
// Create data with repeated strings
var items = Enumerable.Range(0, 100).Select(i => new TestClassWithRepeatedValues
{
Id = i,
Status = i % 3 == 0 ? "Pending" : i % 3 == 1 ? "Processing" : "Completed",
Category = $"Category_{i % 5}",
Priority = i % 2 == 0 ? "High_Priority" : "Low_Priority"
}).ToList();
// Warmup
for (int i = 0; i < 10; i++)
{
var bytes = items.ToBinary();
var result = bytes.BinaryTo<List<TestClassWithRepeatedValues>>();
}
const int iterations = DefaultIterations;
// With interning (default)
var sw = Stopwatch.StartNew();
byte[] withInterning = null!;
for (int i = 0; i < iterations; i++)
{
withInterning = AcBinarySerializer.Serialize(items, AcBinarySerializerOptions.Default);
}
sw.Stop();
var withInterningMs = sw.Elapsed.TotalMilliseconds;
// Without interning
var noInternOptions = new AcBinarySerializerOptions { UseStringInterning = false };
sw.Restart();
byte[] withoutInterning = null!;
for (int i = 0; i < iterations; i++)
{
withoutInterning = AcBinarySerializer.Serialize(items, noInternOptions);
}
sw.Stop();
var withoutInterningMs = sw.Elapsed.TotalMilliseconds;
Console.WriteLine($"=== String Interning Benchmark ({iterations} iterations) ===");
Console.WriteLine($"With interning: {withInterning.Length} bytes, {withInterningMs:F2}ms");
Console.WriteLine($"Without interning: {withoutInterning.Length} bytes, {withoutInterningMs:F2}ms");
Console.WriteLine($"Size savings: {withoutInterning.Length - withInterning.Length} bytes ({100.0 * (withoutInterning.Length - withInterning.Length) / withoutInterning.Length:F1}%)");
Console.WriteLine($"Speed ratio: {withInterningMs / withoutInterningMs:F2}x");
// Verify both deserialize correctly
var result1 = withInterning.BinaryTo<List<TestClassWithRepeatedValues>>();
var result2 = withoutInterning.BinaryTo<List<TestClassWithRepeatedValues>>();
Assert.AreEqual(100, result1!.Count);
Assert.AreEqual(100, result2!.Count);
}
#endregion
#region MessagePack Comparison
[TestMethod]
public void RunMessagePackComparison()
{
// Create test data
var order = TestDataFactory.CreateBenchmarkOrder(3, 3, 3, 4);
// Warmup
for (int i = 0; i < 20; i++)
{
var binBytes = AcBinarySerializer.Serialize(order, AcBinarySerializerOptions.Default);
var binResult = AcBinaryDeserializer.Deserialize<TestOrder>(binBytes);
var msgBytes = MessagePackSerializer.Serialize(order, MsgPackOptions);
var msgResult = MessagePackSerializer.Deserialize<TestOrder>(msgBytes, MsgPackOptions);
}
const int iterations = DefaultIterations;
// === AcBinary Serialize ===
var sw = Stopwatch.StartNew();
byte[] acBinaryData = null!;
for (int i = 0; i < iterations; i++)
{
acBinaryData = AcBinarySerializer.Serialize(order, AcBinarySerializerOptions.Default);
}
sw.Stop();
var acBinarySerMs = sw.Elapsed.TotalMilliseconds;
// === MessagePack Serialize ===
sw.Restart();
byte[] msgPackData = null!;
for (int i = 0; i < iterations; i++)
{
msgPackData = MessagePackSerializer.Serialize(order, MsgPackOptions);
}
sw.Stop();
var msgPackSerMs = sw.Elapsed.TotalMilliseconds;
// === AcBinary Deserialize ===
sw.Restart();
TestOrder? acBinaryResult = null;
for (int i = 0; i < iterations; i++)
{
acBinaryResult = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryData);
}
sw.Stop();
var acBinaryDeserMs = sw.Elapsed.TotalMilliseconds;
// === MessagePack Deserialize ===
sw.Restart();
TestOrder? msgPackResult = null;
for (int i = 0; i < iterations; i++)
{
msgPackResult = MessagePackSerializer.Deserialize<TestOrder>(msgPackData, MsgPackOptions);
}
sw.Stop();
var msgPackDeserMs = sw.Elapsed.TotalMilliseconds;
// Print results
Console.WriteLine($"=== AcBinary vs MessagePack Benchmark ({iterations} iterations) ===");
Console.WriteLine();
Console.WriteLine($"{"Metric",-25} {"AcBinary",12} {"MessagePack",12} {"Ratio",10}");
Console.WriteLine(new string('-', 60));
Console.WriteLine($"{"Size (bytes)",-25} {acBinaryData.Length,12:N0} {msgPackData.Length,12:N0} {100.0 * acBinaryData.Length / msgPackData.Length,9:F1}%");
Console.WriteLine($"{"Serialize (ms)",-25} {acBinarySerMs,12:F2} {msgPackSerMs,12:F2} {acBinarySerMs / msgPackSerMs,9:F2}x");
Console.WriteLine($"{"Deserialize (ms)",-25} {acBinaryDeserMs,12:F2} {msgPackDeserMs,12:F2} {acBinaryDeserMs / msgPackDeserMs,9:F2}x");
Console.WriteLine($"{"Round-trip (ms)",-25} {acBinarySerMs + acBinaryDeserMs,12:F2} {msgPackSerMs + msgPackDeserMs,12:F2} {(acBinarySerMs + acBinaryDeserMs) / (msgPackSerMs + msgPackDeserMs),9:F2}x");
Console.WriteLine();
var sizeDiff = msgPackData.Length - acBinaryData.Length;
if (sizeDiff > 0)
Console.WriteLine($"[OK] AcBinary {sizeDiff:N0} bytes smaller ({100.0 * sizeDiff / msgPackData.Length:F1}% savings)");
else
Console.WriteLine($"[WARN] AcBinary {-sizeDiff:N0} bytes larger");
Assert.IsNotNull(acBinaryResult);
Assert.IsNotNull(msgPackResult);
Assert.AreEqual(order.Id, acBinaryResult.Id);
}
[TestMethod]
public void RunStringInterningVsMessagePack()
{
// Create data with many repeated strings (worst case for MessagePack, best for interning)
var items = Enumerable.Range(0, 200).Select(i => new TestClassWithRepeatedValues
{
Id = i,
Status = i % 3 == 0 ? "PendingStatus" : i % 3 == 1 ? "ProcessingStatus" : "CompletedStatus",
Category = $"Category_{i % 5}",
Priority = i % 2 == 0 ? "HighPriority" : "LowPriority"
}).ToList();
// Warmup
for (int i = 0; i < 20; i++)
{
var b1 = AcBinarySerializer.Serialize(items, AcBinarySerializerOptions.Default);
var r1 = AcBinaryDeserializer.Deserialize<List<TestClassWithRepeatedValues>>(b1);
var b2 = MessagePackSerializer.Serialize(items, MsgPackOptions);
var r2 = MessagePackSerializer.Deserialize<List<TestClassWithRepeatedValues>>(b2, MsgPackOptions);
}
const int iterations = DefaultIterations;
// AcBinary with interning
var sw = Stopwatch.StartNew();
byte[] acWithIntern = null!;
for (int i = 0; i < iterations; i++)
{
acWithIntern = AcBinarySerializer.Serialize(items, AcBinarySerializerOptions.Default);
}
var acSerMs = sw.Elapsed.TotalMilliseconds;
sw.Restart();
for (int i = 0; i < iterations; i++)
{
var _ = AcBinaryDeserializer.Deserialize<List<TestClassWithRepeatedValues>>(acWithIntern);
}
var acDeserMs = sw.Elapsed.TotalMilliseconds;
// MessagePack
sw.Restart();
byte[] msgPack = null!;
for (int i = 0; i < iterations; i++)
{
msgPack = MessagePackSerializer.Serialize(items, MsgPackOptions);
}
var msgSerMs = sw.Elapsed.TotalMilliseconds;
sw.Restart();
for (int i = 0; i < iterations; i++)
{
var _ = MessagePackSerializer.Deserialize<List<TestClassWithRepeatedValues>>(msgPack, MsgPackOptions);
}
var msgDeserMs = sw.Elapsed.TotalMilliseconds;
Console.WriteLine($"=== String Interning Advantage ({iterations} iterations, 200 items with repeated strings) ===");
Console.WriteLine();
Console.WriteLine($"{"Metric",-25} {"AcBinary",12} {"MessagePack",12} {"Ratio",10}");
Console.WriteLine(new string('-', 60));
Console.WriteLine($"{"Size (bytes)",-25} {acWithIntern.Length,12:N0} {msgPack.Length,12:N0} {100.0 * acWithIntern.Length / msgPack.Length,9:F1}%");
Console.WriteLine($"{"Serialize (ms)",-25} {acSerMs,12:F2} {msgSerMs,12:F2} {acSerMs / msgSerMs,9:F2}x");
Console.WriteLine($"{"Deserialize (ms)",-25} {acDeserMs,12:F2} {msgDeserMs,12:F2} {acDeserMs / msgDeserMs,9:F2}x");
Console.WriteLine();
var sizeSaving = msgPack.Length - acWithIntern.Length;
Console.WriteLine($"[OK] String interning saves {sizeSaving:N0} bytes ({100.0 * sizeSaving / msgPack.Length:F1}%)");
Assert.IsTrue(acWithIntern.Length < msgPack.Length, "AcBinary with interning should be smaller");
}
#endregion
#region Full Comparison (WithRef, NoRef, Populate, Merge)
[TestMethod]
public void RunFullBenchmarkComparison()
{
PrintBanner("AcBinary vs MessagePack Full Benchmark");
// 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);
var testOrder = TestDataFactory.CreateOrder(
itemCount: 3,
palletsPerItem: 3,
measurementsPerPallet: 3,
pointsPerMeasurement: 4,
sharedTag: sharedTag,
sharedUser: sharedUser,
sharedMetadata: sharedMeta);
// Options
var withRefOptions = new AcBinarySerializerOptions();
var noRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling();
// Warmup
Console.WriteLine("\nWarming up...");
for (int i = 0; i < 100; i++)
{
_ = AcBinarySerializer.Serialize(testOrder, withRefOptions);
_ = AcBinarySerializer.Serialize(testOrder, noRefOptions);
_ = MessagePackSerializer.Serialize(testOrder, MsgPackOptions);
}
// Pre-serialize
var acBinaryWithRef = AcBinarySerializer.Serialize(testOrder, withRefOptions);
var acBinaryNoRef = AcBinarySerializer.Serialize(testOrder, noRefOptions);
var msgPackData = MessagePackSerializer.Serialize(testOrder, MsgPackOptions);
Console.WriteLine($"Iterations: {DefaultIterations:N0}");
// Size comparison
PrintTableHeader("SIZE COMPARISON");
PrintTableRowSize("AcBinary (WithRef)", acBinaryWithRef.Length, msgPackData.Length);
PrintTableRowSize("AcBinary (NoRef)", acBinaryNoRef.Length, msgPackData.Length);
PrintTableRowSize("MessagePack (baseline)", msgPackData.Length, msgPackData.Length);
PrintTableFooter();
var sw = Stopwatch.StartNew();
// === Serialize WithRef ===
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = AcBinarySerializer.Serialize(testOrder, withRefOptions);
var acWithRefSerMs = sw.Elapsed.TotalMilliseconds;
// === Serialize NoRef ===
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = AcBinarySerializer.Serialize(testOrder, noRefOptions);
var acNoRefSerMs = sw.Elapsed.TotalMilliseconds;
// === MessagePack Serialize ===
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = MessagePackSerializer.Serialize(testOrder, MsgPackOptions);
var msgPackSerMs = sw.Elapsed.TotalMilliseconds;
// === Deserialize WithRef ===
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryWithRef);
var acWithRefDeserMs = sw.Elapsed.TotalMilliseconds;
// === Deserialize NoRef ===
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryNoRef);
var acNoRefDeserMs = sw.Elapsed.TotalMilliseconds;
// === MessagePack Deserialize ===
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = MessagePackSerializer.Deserialize<TestOrder>(msgPackData, MsgPackOptions);
var msgPackDeserMs = sw.Elapsed.TotalMilliseconds;
// === Populate (AcBinary only) ===
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
{
var target = CreatePopulateTarget(testOrder);
AcBinaryDeserializer.Populate(acBinaryNoRef, target);
}
var acPopulateMs = sw.Elapsed.TotalMilliseconds;
// === PopulateMerge (AcBinary only) ===
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
{
var target = CreatePopulateTarget(testOrder);
AcBinaryDeserializer.PopulateMerge(acBinaryNoRef.AsSpan(), target);
}
var acMergeMs = sw.Elapsed.TotalMilliseconds;
// Print performance table
PrintTableHeader("PERFORMANCE COMPARISON (lower is better)");
PrintTableRow("Serialize (WithRef)", acWithRefSerMs, msgPackSerMs);
PrintTableRow("Serialize (NoRef)", acNoRefSerMs, msgPackSerMs);
PrintTableRow("Deserialize (WithRef)", acWithRefDeserMs, msgPackDeserMs);
PrintTableRow("Deserialize (NoRef)", acNoRefDeserMs, msgPackDeserMs);
PrintTableRow("Populate (NoRef)", acPopulateMs, 0);
PrintTableRow("Merge (NoRef)", acMergeMs, 0);
PrintTableRow("Round-trip (WithRef)", acWithRefSerMs + acWithRefDeserMs, msgPackSerMs + msgPackDeserMs);
PrintTableRow("Round-trip (NoRef)", acNoRefSerMs + acNoRefDeserMs, msgPackSerMs + msgPackDeserMs);
PrintTableFooter();
PrintSummary(acBinaryNoRef.Length, msgPackData.Length, acNoRefSerMs, msgPackSerMs, acNoRefDeserMs, msgPackDeserMs);
// Assertions
Assert.IsTrue(acBinaryWithRef.Length < msgPackData.Length, "AcBinary WithRef should be smaller than MessagePack");
Assert.IsTrue(acBinaryNoRef.Length < msgPackData.Length, "AcBinary NoRef should be smaller than MessagePack");
}
[TestMethod]
public void RunWithRefVsNoRefComparison()
{
PrintBanner("AcBinary WithRef vs NoRef Comparison");
// Create test data WITH shared references (to show WithRef advantage)
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedUser = TestDataFactory.CreateUser("shareduser");
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
var testOrder = TestDataFactory.CreateOrder(
itemCount: 5,
palletsPerItem: 4,
measurementsPerPallet: 3,
pointsPerMeasurement: 5,
sharedTag: sharedTag,
sharedUser: sharedUser,
sharedMetadata: sharedMeta);
var withRefOptions = new AcBinarySerializerOptions();
var noRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling();
// Warmup
Console.WriteLine("Warming up...");
for (int i = 0; i < 50; i++)
{
_ = AcBinarySerializer.Serialize(testOrder, withRefOptions);
_ = AcBinarySerializer.Serialize(testOrder, noRefOptions);
}
var withRefData = AcBinarySerializer.Serialize(testOrder, withRefOptions);
var noRefData = AcBinarySerializer.Serialize(testOrder, noRefOptions);
Console.WriteLine($"Iterations: {DefaultIterations:N0}");
// Size comparison
PrintBanner("SIZE COMPARISON (bytes)");
Console.WriteLine($"WithRef : {withRefData.Length,12:N0} (baseline)");
var sizeDiff = noRefData.Length - withRefData.Length;
var ratio = withRefData.Length == 0 ? 0 : 100.0 * noRefData.Length / withRefData.Length;
Console.WriteLine($"NoRef : {noRefData.Length,12:N0} (diff {sizeDiff:+#;-#;0}) => {ratio:F1}% of WithRef");
var sw = Stopwatch.StartNew();
// Serialize WithRef
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = AcBinarySerializer.Serialize(testOrder, withRefOptions);
var withRefSerMs = sw.Elapsed.TotalMilliseconds;
// Serialize NoRef
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = AcBinarySerializer.Serialize(testOrder, noRefOptions);
var noRefSerMs = sw.Elapsed.TotalMilliseconds;
// Deserialize WithRef
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = AcBinaryDeserializer.Deserialize<TestOrder>(withRefData);
var withRefDeserMs = sw.Elapsed.TotalMilliseconds;
// Deserialize NoRef
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = AcBinaryDeserializer.Deserialize<TestOrder>(noRefData);
var noRefDeserMs = sw.Elapsed.TotalMilliseconds;
PrintBanner("PERFORMANCE COMPARISON (ms)");
Console.WriteLine($"Serialize -> WithRef: {withRefSerMs,8:F2} | NoRef: {noRefSerMs,8:F2}");
Console.WriteLine($"Deserialize-> WithRef: {withRefDeserMs,8:F2} | NoRef: {noRefDeserMs,8:F2}");
Console.WriteLine($"Round-trip -> WithRef: {withRefSerMs + withRefDeserMs,8:F2} | NoRef: {noRefSerMs + noRefDeserMs,8:F2}");
if (withRefData.Length < noRefData.Length)
{
Console.WriteLine($"[OK] WithRef saves {noRefData.Length - withRefData.Length:N0} bytes by deduplicating shared references.");
}
else
{
Console.WriteLine($"[INFO] NoRef saves {withRefData.Length - noRefData.Length:N0} bytes because it skips reference metadata.");
}
// Verify correctness
var resultWithRef = AcBinaryDeserializer.Deserialize<TestOrder>(withRefData);
var resultNoRef = AcBinaryDeserializer.Deserialize<TestOrder>(noRefData);
Assert.IsNotNull(resultWithRef);
Assert.IsNotNull(resultNoRef);
Assert.AreEqual(testOrder.Id, resultWithRef.Id);
Assert.AreEqual(testOrder.Id, resultNoRef.Id);
}
[TestMethod]
public void RunPopulateAndMergeBenchmark()
{
PrintBanner("AcBinary Populate & Merge Benchmark");
TestDataFactory.ResetIdCounter();
var testOrder = TestDataFactory.CreateBenchmarkOrder(
itemCount: 3,
palletsPerItem: 3,
measurementsPerPallet: 3,
pointsPerMeasurement: 4);
var options = AcBinarySerializerOptions.WithoutReferenceHandling();
var binaryData = AcBinarySerializer.Serialize(testOrder, options);
Console.WriteLine("Warming up...");
for (int i = 0; i < 50; i++)
{
var target = CreatePopulateTarget(testOrder);
AcBinaryDeserializer.Populate(binaryData, target);
AcBinaryDeserializer.PopulateMerge(binaryData.AsSpan(), target);
}
Console.WriteLine($"Iterations: {DefaultIterations:N0}");
Console.WriteLine($"Data size: {binaryData.Length:N0} bytes");
var sw = Stopwatch.StartNew();
// Deserialize (creates new object)
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = AcBinaryDeserializer.Deserialize<TestOrder>(binaryData);
var deserializeMs = sw.Elapsed.TotalMilliseconds;
// Populate (reuses existing object)
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
{
var target = CreatePopulateTarget(testOrder);
AcBinaryDeserializer.Populate(binaryData, target);
}
var populateMs = sw.Elapsed.TotalMilliseconds;
// PopulateMerge (IId-based merge)
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
{
var target = CreatePopulateTarget(testOrder);
AcBinaryDeserializer.PopulateMerge(binaryData.AsSpan(), target);
}
var mergeMs = sw.Elapsed.TotalMilliseconds;
// PopulateMerge with RemoveOrphanedItems
var mergeWithRemoveOptions = new AcBinarySerializerOptions { RemoveOrphanedItems = true };
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
{
var target = CreatePopulateTarget(testOrder);
AcBinaryDeserializer.PopulateMerge(binaryData.AsSpan(), target, mergeWithRemoveOptions);
}
var mergeWithRemoveMs = sw.Elapsed.TotalMilliseconds;
PrintBanner("OPERATION COMPARISON (ms)");
Console.WriteLine($"Deserialize (new object): {deserializeMs:F2} (baseline)");
Console.WriteLine($"Populate (reuse obj) : {populateMs:F2} ({populateMs / deserializeMs:F2}x of baseline)");
Console.WriteLine($"PopulateMerge : {mergeMs:F2} ({mergeMs / deserializeMs:F2}x of baseline)");
Console.WriteLine($"PopulateMerge + cleanup: {mergeWithRemoveMs:F2} ({mergeWithRemoveMs / deserializeMs:F2}x of baseline)");
Console.WriteLine("[INFO] Populate/Merge reuse existing objects - ideal for UI data binding scenarios.");
Assert.IsTrue(true); // Test passed if no exceptions
}
#endregion
#region Test Models
public class TestClassWithRepeatedValues
{
public int Id { get; set; }
public string Status { get; set; } = "";
public string Category { get; set; } = "";
public string Priority { get; set; } = "";
}
#endregion
}

View File

@ -0,0 +1,389 @@
using AyCode.Interfaces.Entities;
using AyCode.Interfaces.TimeStampInfo;
using Newtonsoft.Json;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq.Expressions;
using static System.Net.Mime.MediaTypeNames;
namespace AyCode.Core.Tests.Serialization;
public abstract partial class BaseEntity : IBaseEntity
{
/// <summary>
/// Gets or sets the entity identifier
/// </summary>
public int Id { get; set; }
public override string ToString()
{
return $"{GetType().Name} [Id: {Id}]";
}
}
public interface IBaseEntity //: IEntityInt
{
public int Id { get; set; }
}
public abstract class MgEntityBase : BaseEntity, IEntityInt
{
public override string ToString()
{
return $"{GetType().Name}; Id: {Id}";
}
}
public interface IMgStockTaking : IEntityInt, ITimeStampInfo
{
DateTime StartDateTime { get; set; }
bool IsClosed { get; set; }
bool IsReadyForClose();
}
public abstract class MgStockTaking<TStockTakingItem> : MgEntityBase, IMgStockTaking
where TStockTakingItem : class, IMgStockTakingItem
{
public DateTime StartDateTime { get; set; }
public bool IsClosed { get; set; }
public abstract bool IsReadyForClose();
public List<TStockTakingItem>? StockTakingItems { get; set; }
public int Creator { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
}
public interface IMgStockTakingItem : IEntityInt, ITimeStampInfo
{
int StockTakingId { get; set; }
int ProductId { get; set; }
bool IsMeasured { get; set; }
int OriginalStockQuantity { get; set; }
int MeasuredStockQuantity { get; set; }
}
public abstract class MgStockTakingItem<TStockTaking, TProduct> : MgEntityBase, IMgStockTakingItem
where TStockTaking : class, IMgStockTaking
where TProduct : class, IMgProductDto
{
public int StockTakingId { get; set; }
public int ProductId { get; set; }
public bool IsMeasured { get; set; }
public int OriginalStockQuantity { get; set; }
public int MeasuredStockQuantity { get; set; }
public TStockTaking? StockTaking { get; set; }
public TProduct? Product { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
}
public class StockTaking : MgStockTaking<StockTakingItem>
{
public override bool IsReadyForClose()
{
if (StockTakingItems == null || StockTakingItems.Count == 0) return false;
return StockTakingItems
.Where(stockTakingItem => stockTakingItem is { IsRequiredForMeasuring: true, IsInvalid: false })
.All(x => x.IsMeasured);
}
}
public class StockTakingItem : MgStockTakingItem<StockTaking, ProductDto>
{
public bool IsMeasurable { get; set; }
public double OriginalNetWeight { get; set; }
public double MeasuredNetWeight { get; set; }
public int InProcessOrdersQuantity { get; set; }
[NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public int TotalOriginalQuantity => OriginalStockQuantity + InProcessOrdersQuantity;
[NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public int QuantityDiff => IsMeasured ? MeasuredStockQuantity - TotalOriginalQuantity : 0;
[NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public double NetWeightDiff => IsMeasurable && IsMeasured ? double.Round(MeasuredNetWeight - OriginalNetWeight, 1) : 0d;
public List<StockTakingItemPallet>? StockTakingItemPallets { get; set; }
[NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public bool IsRequiredForMeasuring => !IsInvalid && (TotalOriginalQuantity != 0 || OriginalNetWeight != 0);
[NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public bool IsInvalid => TotalOriginalQuantity < 0;
[NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public string DisplayText
{
get
{
if (IsInvalid) return $"[HIBA] {Product!.Name}";
if (IsMeasured) return $"[KÉSZ] {Product!.Name}";
return IsRequiredForMeasuring ? $"[KÖT] {Product!.Name}" : $"{Product!.Name}";
}
}
}
public class StockTakingItemPallet : MeasuringItemPalletBase
{
public int StockTakingItemId
{
get => ForeignItemId;
set => ForeignItemId = value;
}
public StockTakingItem? StockTakingItem { get; set; }
public override double CalculateNetWeight() => base.CalculateNetWeight();
public override bool IsValidSafeMeasuringValues()
{
return StockTakingItemId > 0 && TrayQuantity >= 0 && TareWeight >= 0 && PalletWeight >= 0 && NetWeight >= 0 && GrossWeight >= 0;
}
public override bool IsValidMeasuringValues(bool isMeasurable)
{
return StockTakingItemId > 0 && TrayQuantity >= 0 &&
((!isMeasurable && NetWeight == 0 && GrossWeight == 0 && PalletWeight == 0 && TareWeight == 0)
|| (isMeasurable && NetWeight >= 0 && GrossWeight >= 0 && PalletWeight >= 0 && TareWeight >= 0));
}
public override void SetParentPropToNull() => StockTakingItem = null;
}
public abstract class MeasuringItemPalletBase : MgEntityBase
{
private double _palletWeight;
private double _grossWeight;
private double _tareWeight;
protected int ForeignItemId;
public int ForeignKey => ForeignItemId;
public int TrayQuantity { get; set; }
public double TareWeight
{
get => _tareWeight;
set => _tareWeight = double.Round(value, 1);
}
public double PalletWeight
{
get => _palletWeight;
set => _palletWeight = double.Round(value, 0);
}
[System.ComponentModel.DataAnnotations.Schema.NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public double NetWeight
{
get => CalculateNetWeight();
set => throw new Exception($"MeasuringItemPalletBase.NetWeight not set");
}
public double GrossWeight
{
get => _grossWeight;
set => _grossWeight = double.Round(value, 1);
}
public bool IsMeasured { get; set; }
public int? CreatorId { get; set; }
public int? ModifierId { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
public abstract void SetParentPropToNull();
public void SetForeignKey(int foreignKey) => ForeignItemId = foreignKey;
public virtual double CalculateNetWeight() => double.Round(GrossWeight - PalletWeight - (TareWeight * TrayQuantity), 1);
public virtual bool IsValidSafeMeasuringValues()
{
return TrayQuantity > 0 && TareWeight >= 0 && PalletWeight >= 0 && NetWeight >= 0 && GrossWeight >= 0;
}
public virtual bool IsValidMeasuringValues(bool isMeasurable)
{
return TrayQuantity > 0 &&
((!isMeasurable && NetWeight == 0 && GrossWeight == 0 && PalletWeight == 0 && TareWeight == 0)
|| (isMeasurable && NetWeight > 0 && GrossWeight > 0 && PalletWeight >= 0 && TareWeight >= 0));
}
public bool IsMeasuredAndValid(bool isMeasurable)
{
return Id > 0 && IsMeasured && IsValidMeasuringValues(isMeasurable);
}
public virtual void SetupCustomItemPalletMeauringValues(bool isMeasurable)
{
if (!isMeasurable)
{
TareWeight = 0;
PalletWeight = 0;
GrossWeight = 0;
}
IsMeasured = IsValidMeasuringValues(isMeasurable);
}
public override string ToString()
{
return $"{base.ToString()} [ForeignItemId: {ForeignItemId}; IsMeasured: {IsMeasured}; PalletWeight: {PalletWeight}; TareWeight: {TareWeight}; Quantity: {TrayQuantity}; NetWeight: {NetWeight}; GrossWeight: {GrossWeight}]";
}
}
public interface IMgProductDto : IEntityInt
{
int ProductTypeId { get; set; }
int ParentGroupedProductId { get; set; }
string Name { get; set; }
string ShortDescription { get; set; }
string FullDescription { get; set; }
int WarehouseId { get; set; }
decimal Price { get; set; }
int StockQuantity { get; set; }
decimal ProductCost { get; set; }
decimal Weight { get; set; }
decimal Length { get; set; }
decimal Width { get; set; }
decimal Height { get; set; }
}
public class ProductDto : MgProductDto
{
public List<GenericAttributeDto> GenericAttributes { get; set; }
public ProductDto() :base()
{ }
public ProductDto(int productId) : base(productId)
{ }
//public ProductDto(Product product) : base(product)
//{ }
[NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public int AvailableQuantity => StockQuantity;
public bool HasMeasuringValues() => Id > 0;
}
public abstract class MgProductDto : MgEntityBase, /*Product,*/ IMgProductDto//IModelDtoBase<Product>//, IDiscountSupported<DiscountProductMapping>
{
//public int Id { get; set; }
public int ProductTypeId { get; set; }
public int ParentGroupedProductId { get; set; }
public string Name { get; set; }
public string ShortDescription { get; set; }
public string FullDescription { get; set; }
public int WarehouseId { get; set; }
public decimal Price { get; set; }
public int StockQuantity { get; set; }
public decimal ProductCost { get; set; }
public decimal Weight { get; set; }
public decimal Length { get; set; }
public decimal Width { get; set; }
public decimal Height { get; set; }
public bool Deleted { get; set; }
public bool SubjectToAcl { get; set; }
public bool LimitedToStores { get; set; }
protected MgProductDto() :base()
{ }
protected MgProductDto(int productId)
{
Id = productId;
}
}
public class GenericAttributeDto : MgGenericAttributeDto
{
}
public abstract class MgGenericAttributeDto : GenericAttribute
{
public GenericAttribute CreateMainEntity()
{
var mainEntity = Activator.CreateInstance<GenericAttribute>();
CopyDtoValuesToEntity(mainEntity);
mainEntity.CreatedOrUpdatedDateUTC = DateTime.UtcNow;
return mainEntity;
}
public void CopyDtoValuesToEntity(GenericAttribute entity)
{
entity.Id = Id;
entity.Key = Key;
entity.Value = Value;
entity.EntityId = EntityId;
entity.KeyGroup = KeyGroup;
entity.StoreId = StoreId;
entity.CreatedOrUpdatedDateUTC = CreatedOrUpdatedDateUTC;
}
public void CopyEntityValuesToDto(GenericAttribute entity)
{
Id = entity.Id;
Key = entity.Key;
Value = entity.Value;
EntityId = entity.EntityId;
KeyGroup = entity.KeyGroup;
StoreId = entity.StoreId;
CreatedOrUpdatedDateUTC = entity.CreatedOrUpdatedDateUTC;
}
}
public partial class GenericAttribute : BaseEntity
{
/// <summary>
/// Gets or sets the entity identifier
/// </summary>
public int EntityId { get; set; }
/// <summary>
/// Gets or sets the key group
/// </summary>
public string KeyGroup { get; set; }
/// <summary>
/// Gets or sets the key
/// </summary>
public string Key { get; set; }
/// <summary>
/// Gets or sets the value
/// </summary>
public string Value { get; set; }
/// <summary>
/// Gets or sets the store identifier
/// </summary>
public int StoreId { get; set; }
/// <summary>
/// Gets or sets the created or updated date
/// </summary>
public DateTime? CreatedOrUpdatedDateUTC { get; set; }
}

View File

@ -1,5 +1,6 @@
using AyCode.Core.Extensions;
using AyCode.Core.Interfaces;
using AyCode.Core.Serializers.Jsons;
using MessagePack;
using MongoDB.Bson.Serialization.Attributes;
using Newtonsoft.Json;

View File

@ -42,6 +42,8 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
ProjectSection(SolutionItems) = preProject
AyCode.Core.targets = AyCode.Core.targets
RunQuickBenchmark.bat = RunQuickBenchmark.bat
RunQuickBenchmark.ps1 = RunQuickBenchmark.ps1
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AyCode.Benchmark", "AyCode.Benchmark\AyCode.Benchmark.csproj", "{A20861A9-411E-6150-BF5C-69E8196E5D22}"

View File

@ -0,0 +1,187 @@
using System.Buffers;
using System.IO.Compression;
using System.Runtime.CompilerServices;
using System.Text;
namespace AyCode.Core.Compression;
/// <summary>
/// GZip compression/decompression helper for SignalR message transport.
/// Matches BrotliHelper API to make switching effortless.
/// </summary>
public static class GzipHelper
{
private const int DefaultBufferSize = 4096;
private const int MaxStackAllocSize = 1024;
#region Compression
/// <summary>
/// Compresses a string using GZip compression with pooled buffers.
/// </summary>
public static byte[] Compress(string text, CompressionLevel compressionLevel = CompressionLevel.Optimal)
{
if (string.IsNullOrEmpty(text))
return [];
var maxByteCount = Encoding.UTF8.GetMaxByteCount(text.Length);
if (maxByteCount <= MaxStackAllocSize)
{
Span<byte> utf8Bytes = stackalloc byte[maxByteCount];
var actualLength = Encoding.UTF8.GetBytes(text.AsSpan(), utf8Bytes);
return CompressSpan(utf8Bytes[..actualLength], compressionLevel);
}
var rentedBuffer = ArrayPool<byte>.Shared.Rent(maxByteCount);
try
{
var actualLength = Encoding.UTF8.GetBytes(text.AsSpan(), rentedBuffer);
return CompressSpan(rentedBuffer.AsSpan(0, actualLength), compressionLevel);
}
finally
{
ArrayPool<byte>.Shared.Return(rentedBuffer);
}
}
/// <summary>
/// Compresses a byte array using GZip compression.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte[] Compress(byte[] data, CompressionLevel compressionLevel = CompressionLevel.Optimal)
=> data == null || data.Length == 0 ? [] : CompressSpan(data.AsSpan(), compressionLevel);
/// <summary>
/// Compresses a ReadOnlySpan using GZip compression with pooled output buffer.
/// </summary>
public static byte[] CompressSpan(ReadOnlySpan<byte> data, CompressionLevel compressionLevel = CompressionLevel.Optimal)
{
if (data.IsEmpty)
return [];
using var outputStream = new MemoryStream();
using (var gzipStream = new GZipStream(outputStream, compressionLevel, leaveOpen: true))
{
gzipStream.Write(data);
}
return outputStream.ToArray();
}
#endregion
#region Decompression
/// <summary>
/// Decompresses GZip-compressed data to a string.
/// </summary>
public static string DecompressToString(byte[] compressedData)
{
if (compressedData == null || compressedData.Length == 0)
return string.Empty;
var decompressedBytes = Decompress(compressedData);
return Encoding.UTF8.GetString(decompressedBytes);
}
/// <summary>
/// Decompresses GZip-compressed data to a byte array.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte[] Decompress(byte[] compressedData)
{
if (compressedData == null || compressedData.Length == 0)
return [];
return DecompressCore(compressedData);
}
/// <summary>
/// Decompresses GZip-compressed data from a ReadOnlySpan.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte[] DecompressSpan(ReadOnlySpan<byte> compressedData)
{
if (compressedData.IsEmpty)
return [];
return DecompressCore(compressedData.ToArray());
}
private static byte[] DecompressCore(byte[] compressedData)
{
using var inputStream = new MemoryStream(compressedData, writable: false);
using var gzipStream = new GZipStream(inputStream, CompressionMode.Decompress);
using var outputStream = new MemoryStream();
gzipStream.CopyTo(outputStream);
return outputStream.ToArray();
}
/// <summary>
/// Decompresses to a rented buffer. Caller must return the buffer to ArrayPool.
/// Returns the actual decompressed length.
/// </summary>
public static (byte[] Buffer, int Length) DecompressToRentedBuffer(ReadOnlySpan<byte> compressedData)
{
if (compressedData.IsEmpty)
return ([], 0);
var estimatedSize = Math.Max(compressedData.Length * 4, DefaultBufferSize);
var outputBuffer = ArrayPool<byte>.Shared.Rent(estimatedSize);
using var inputStream = new MemoryStream(compressedData.ToArray(), writable: false);
using var gzipStream = new GZipStream(inputStream, CompressionMode.Decompress);
var totalRead = 0;
int bytesRead;
while ((bytesRead = gzipStream.Read(outputBuffer.AsSpan(totalRead))) > 0)
{
totalRead += bytesRead;
if (totalRead >= outputBuffer.Length - DefaultBufferSize)
{
var newBuffer = ArrayPool<byte>.Shared.Rent(outputBuffer.Length * 2);
outputBuffer.AsSpan(0, totalRead).CopyTo(newBuffer);
ArrayPool<byte>.Shared.Return(outputBuffer);
outputBuffer = newBuffer;
}
}
return (outputBuffer, totalRead);
}
#endregion
#region Utility
/// <summary>
/// Checks if the data appears to be GZip compressed.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsGzipCompressed(byte[] data)
{
if (data == null || data.Length < 2)
return false;
// 0x1F, 0x8B are the fixed gzip header bytes
if (data[0] != 0x1F || data[1] != 0x8B)
return false;
try
{
using var inputStream = new MemoryStream(data, 0, Math.Min(data.Length, 64), writable: false);
using var gzipStream = new GZipStream(inputStream, CompressionMode.Decompress);
Span<byte> buffer = stackalloc byte[1];
return gzipStream.Read(buffer) >= 0;
}
catch
{
return false;
}
}
#endregion
}

File diff suppressed because it is too large Load Diff

View File

@ -5,9 +5,12 @@ using System.Runtime.CompilerServices;
using System.Runtime.Serialization;
using System.Text;
using AyCode.Core.Interfaces;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Jsons;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using static AyCode.Core.Extensions.JsonUtilities;
using static AyCode.Core.Helpers.JsonUtilities;
using ReferenceEqualityComparer = AyCode.Core.Serializers.Jsons.ReferenceEqualityComparer;
namespace AyCode.Core.Extensions;

View File

@ -2,14 +2,14 @@ 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 AyCode.Core.Serializers.Jsons;
using Newtonsoft.Json;
namespace AyCode.Core.Extensions;
namespace AyCode.Core.Helpers;
/// <summary>
/// Cached result for IId type info lookup.

View File

@ -1,8 +1,6 @@
using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Reflection;
namespace AyCode.Core.Extensions;
namespace AyCode.Core.Helpers;
public static class PropertyHelper
{

View File

@ -0,0 +1,517 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
namespace AyCode.Core.Serializers.Binaries;
public static partial class AcBinaryDeserializer
{
internal ref struct BinaryDeserializationContext
{
private readonly ReadOnlySpan<byte> _buffer;
private int _position;
private List<string>? _internedStrings;
private List<string>? _propertyNames;
private Dictionary<int, object>? _objectReferences;
private Dictionary<int, string>? _stringCache;
private readonly byte _minStringInternLength;
private readonly bool _useStringCaching;
private readonly int _maxCachedStringLength;
public bool HasMetadata { get; private set; }
public bool HasReferenceHandling { get; private set; }
public bool IsMergeMode { readonly get; set; }
public bool RemoveOrphanedItems { readonly get; set; }
public bool IsAtEnd => _position >= _buffer.Length;
public int Position => _position;
public byte MinStringInternLength => _minStringInternLength;
public BinaryDeserializationContext(ReadOnlySpan<byte> data)
: this(data, AcBinarySerializerOptions.Default)
{
}
public BinaryDeserializationContext(ReadOnlySpan<byte> data, AcBinarySerializerOptions options)
{
_buffer = data;
_position = 0;
_internedStrings = null;
_propertyNames = null;
_objectReferences = null;
_stringCache = null;
HasMetadata = false;
HasReferenceHandling = false;
IsMergeMode = false;
RemoveOrphanedItems = false;
_minStringInternLength = options.MinStringInternLength;
_useStringCaching = options.UseStringCaching;
_maxCachedStringLength = options.MaxCachedStringLength;
}
public void ReadHeader()
{
if (_buffer.Length < 2)
{
throw new AcBinaryDeserializationException("Binary payload is too short to contain a header.");
}
var version = ReadByteInternal();
if (version != AcBinarySerializerOptions.FormatVersion)
{
throw new AcBinaryDeserializationException(
$"Unsupported binary format version '{version}'. Expected '{AcBinarySerializerOptions.FormatVersion}'.",
_position - 1);
}
var marker = ReadByteInternal();
var hasPropertyTable = false;
var hasInternTable = false;
if (marker == BinaryTypeCode.MetadataHeader)
{
hasPropertyTable = true;
HasReferenceHandling = true;
}
else if (marker == BinaryTypeCode.NoMetadataHeader)
{
HasReferenceHandling = true;
}
else if ((marker & 0xF0) == BinaryTypeCode.HeaderFlagsBase)
{
var flags = (byte)(marker & 0x0F);
hasPropertyTable = (flags & BinaryTypeCode.HeaderFlag_Metadata) != 0;
HasReferenceHandling = (flags & BinaryTypeCode.HeaderFlag_ReferenceHandling) != 0;
hasInternTable = (flags & BinaryTypeCode.HeaderFlag_StringInternTable) != 0;
}
else
{
throw new AcBinaryDeserializationException(
$"Unsupported binary header marker '{marker}'.",
_position - 1);
}
HasMetadata = hasPropertyTable;
if (hasPropertyTable)
{
var propertyCount = (int)ReadVarUInt();
_propertyNames = new List<string>(propertyCount);
for (var i = 0; i < propertyCount; i++)
{
_propertyNames.Add(ReadHeaderString());
}
}
if (hasInternTable)
{
var internCount = (int)ReadVarUInt();
_internedStrings = new List<string>(internCount);
for (var i = 0; i < internCount; i++)
{
_internedStrings.Add(ReadHeaderString());
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public byte ReadByte() => ReadByteInternal();
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private byte ReadByteInternal()
{
if (_position >= _buffer.Length)
{
throw new AcBinaryDeserializationException("Unexpected end of binary payload.", _position);
}
return _buffer[_position++];
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public byte PeekByte()
{
if (_position >= _buffer.Length)
{
throw new AcBinaryDeserializationException("Unexpected end of binary payload.", _position);
}
return _buffer[_position];
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public short ReadInt16Unsafe()
{
EnsureAvailable(2);
var value = BinaryPrimitives.ReadInt16LittleEndian(_buffer.Slice(_position, 2));
_position += 2;
return value;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ushort ReadUInt16Unsafe()
{
EnsureAvailable(2);
var value = BinaryPrimitives.ReadUInt16LittleEndian(_buffer.Slice(_position, 2));
_position += 2;
return value;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public char ReadCharUnsafe()
{
EnsureAvailable(2);
var value = (char)BinaryPrimitives.ReadUInt16LittleEndian(_buffer.Slice(_position, 2));
_position += 2;
return value;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ReadSingleUnsafe()
{
EnsureAvailable(4);
var bits = BinaryPrimitives.ReadInt32LittleEndian(_buffer.Slice(_position, 4));
_position += 4;
return BitConverter.Int32BitsToSingle(bits);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public double ReadDoubleUnsafe()
{
EnsureAvailable(8);
var bits = BinaryPrimitives.ReadInt64LittleEndian(_buffer.Slice(_position, 8));
_position += 8;
return BitConverter.Int64BitsToDouble(bits);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public decimal ReadDecimalUnsafe()
{
EnsureAvailable(16);
var ints = MemoryMarshal.Cast<byte, int>(_buffer.Slice(_position, 16));
var lo = ints[0];
var mid = ints[1];
var hi = ints[2];
var flags = ints[3];
var isNegative = (flags & unchecked((int)0x80000000)) != 0;
var scale = (byte)((flags >> 16) & 0x7F);
_position += 16;
return new decimal(lo, mid, hi, isNegative, scale);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public DateTime ReadDateTimeUnsafe()
{
EnsureAvailable(9);
var ticks = BinaryPrimitives.ReadInt64LittleEndian(_buffer.Slice(_position, 8));
var kind = (DateTimeKind)_buffer[_position + 8];
_position += 9;
return new DateTime(ticks, kind);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public DateTimeOffset ReadDateTimeOffsetUnsafe()
{
EnsureAvailable(10);
var utcTicks = BinaryPrimitives.ReadInt64LittleEndian(_buffer.Slice(_position, 8));
var offsetMinutes = BinaryPrimitives.ReadInt16LittleEndian(_buffer.Slice(_position + 8, 2));
_position += 10;
var utcValue = new DateTime(utcTicks, DateTimeKind.Utc);
return new DateTimeOffset(utcValue).ToOffset(TimeSpan.FromMinutes(offsetMinutes));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TimeSpan ReadTimeSpanUnsafe()
{
EnsureAvailable(8);
var ticks = BinaryPrimitives.ReadInt64LittleEndian(_buffer.Slice(_position, 8));
_position += 8;
return new TimeSpan(ticks);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Guid ReadGuidUnsafe()
{
EnsureAvailable(16);
var value = new Guid(_buffer.Slice(_position, 16));
_position += 16;
return value;
}
/// <summary>
/// Optimized VarInt reader with fast path for 1-2 byte values (most common case).
/// Uses ZigZag decoding to handle signed integers.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int ReadVarInt()
{
var raw = ReadVarUInt();
// ZigZag decode: handle full uint range before casting to int
// This correctly handles values like int.MaxValue which encode to uint > int.MaxValue
var value = (int)(raw >> 1) ^ -(int)(raw & 1);
return value;
}
/// <summary>
/// Optimized VarUInt reader with fast path for 1-2 byte values.
/// Most VarInts in real data are small (property indices, array lengths, etc.)
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public uint ReadVarUInt()
{
// Fast path: single byte (0-127) - ~70% of cases
var b0 = _buffer[_position];
if ((b0 & 0x80) == 0)
{
_position++;
return b0;
}
// Fast path: two bytes (128-16383) - ~25% of cases
if (_position + 1 < _buffer.Length)
{
var b1 = _buffer[_position + 1];
if ((b1 & 0x80) == 0)
{
_position += 2;
return (uint)(b0 & 0x7F) | ((uint)b1 << 7);
}
}
// Slow path: 3+ bytes - ~5% of cases
return ReadVarUIntSlow();
}
private uint ReadVarUIntSlow()
{
uint value = 0;
var shift = 0;
while (true)
{
var b = ReadByteInternal();
value |= (uint)(b & 0x7F) << shift;
if ((b & 0x80) == 0)
{
break;
}
shift += 7;
if (shift > 35)
{
throw new AcBinaryDeserializationException("Invalid VarUInt encoding.", _position);
}
}
return value;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public long ReadVarLong()
{
var raw = ReadVarULong();
var value = (long)(raw >> 1) ^ -((long)raw & 1);
return value;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ulong ReadVarULong()
{
ulong value = 0;
var shift = 0;
while (true)
{
var b = ReadByteInternal();
value |= (ulong)(b & 0x7F) << shift;
if ((b & 0x80) == 0)
{
break;
}
shift += 7;
if (shift > 70)
{
throw new AcBinaryDeserializationException("Invalid VarULong encoding.", _position);
}
}
return value;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public byte[] ReadBytes(int length)
{
if (length == 0)
{
return Array.Empty<byte>();
}
EnsureAvailable(length);
var result = GC.AllocateUninitializedArray<byte>(length);
_buffer.Slice(_position, length).CopyTo(result);
_position += length;
return result;
}
/// <summary>
/// Read UTF8 string with optional caching for WASM optimization.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public string ReadStringUtf8(int length)
{
if (length == 0)
{
return string.Empty;
}
EnsureAvailable(length);
// WASM optimization: cache short strings to reduce allocations
if (_useStringCaching && length <= _maxCachedStringLength)
{
return ReadStringUtf8Cached(length);
}
var value = Utf8NoBom.GetString(_buffer.Slice(_position, length));
_position += length;
return value;
}
/// <summary>
/// Read string with caching - reduces allocations in WASM.
/// </summary>
private string ReadStringUtf8Cached(int length)
{
// Create hash from ALL bytes for short strings to avoid collisions
// like "Creator" vs "Created" (same length, same prefix)
var slice = _buffer.Slice(_position, length);
var hash = ComputeStringHashFull(slice);
_stringCache ??= new Dictionary<int, string>(128);
if (_stringCache.TryGetValue(hash, out var cached))
{
// Hash includes all bytes for short strings, so collision is extremely unlikely
// For longer strings, we still verify length as a sanity check
if (cached.Length == length)
{
_position += length;
return cached;
}
// Hash collision with different length - fall through to read new value
}
var value = Utf8NoBom.GetString(slice);
_stringCache[hash] = value;
_position += length;
return value;
}
/// <summary>
/// Compute hash that includes ALL bytes for short strings to avoid collisions.
/// For strings like "Creator" vs "Created" (7 bytes, same prefix), we need full content.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int ComputeStringHashFull(ReadOnlySpan<byte> data)
{
// For strings up to 32 bytes (covers most property names), hash ALL bytes
// This completely eliminates collisions like Creator/Created
if (data.Length <= 32)
{
var hash = new HashCode();
hash.AddBytes(data);
return hash.ToHashCode();
}
// For longer strings (rare for property names), use sampling strategy:
// first 8 bytes + last 8 bytes + middle 8 bytes + length
// This provides good collision resistance with O(1) performance
var h = new HashCode();
h.Add(data.Length);
h.AddBytes(data.Slice(0, 8));
h.AddBytes(data.Slice(data.Length - 8, 8));
h.AddBytes(data.Slice(data.Length / 2 - 4, 8));
return h.ToHashCode();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Skip(int count)
{
EnsureAvailable(count);
_position += count;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int RegisterInternedString(string value)
{
_internedStrings ??= new List<string>();
_internedStrings.Add(value);
return _internedStrings.Count - 1;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public string GetInternedString(int index)
{
if (_internedStrings == null || (uint)index >= (uint)_internedStrings.Count)
{
throw new AcBinaryDeserializationException($"Invalid interned string index '{index}'.", _position);
}
return _internedStrings[index];
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public string GetPropertyName(int index)
{
if (_propertyNames == null || (uint)index >= (uint)_propertyNames.Count)
{
throw new AcBinaryDeserializationException($"Invalid property metadata index '{index}'.", _position);
}
return _propertyNames[index];
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void RegisterObject(int refId, object instance)
{
if (refId <= 0)
{
return;
}
_objectReferences ??= new Dictionary<int, object>(16);
_objectReferences[refId] = instance;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public object? GetReferencedObject(int refId)
{
if (refId <= 0)
{
return null;
}
if (_objectReferences == null || !_objectReferences.TryGetValue(refId, out var value))
{
throw new AcBinaryDeserializationException($"Unknown object reference id '{refId}'.", _position);
}
return value;
}
private void EnsureAvailable(int length)
{
if (_position > _buffer.Length - length)
{
throw new AcBinaryDeserializationException("Unexpected end of binary payload.", _position);
}
}
private string ReadHeaderString()
{
var byteLength = (int)ReadVarUInt();
return ReadStringUtf8(byteLength);
}
}
}

View File

@ -0,0 +1,216 @@
using System;
using System.Collections;
using System.Collections.Frozen;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Binaries;
public static partial class AcBinaryDeserializer
{
internal sealed class BinaryDeserializeTypeMetadata
{
private readonly FrozenDictionary<string, BinaryPropertySetterInfo> _properties;
public BinaryPropertySetterInfo[] PropertiesArray { get; }
public Func<object>? CompiledConstructor { get; }
public BinaryDeserializeTypeMetadata(Type type)
{
PropertiesArray = type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(static p => p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0 &&
p.GetMethod is { IsPublic: true } &&
p.SetMethod is { IsPublic: true } &&
!HasJsonIgnoreAttribute(p))
.Select(static p => new BinaryPropertySetterInfo(p))
.ToArray();
_properties = PropertiesArray.Length == 0
? FrozenDictionary<string, BinaryPropertySetterInfo>.Empty
: PropertiesArray.ToFrozenDictionary(static p => p.Name, static p => p, StringComparer.Ordinal);
CompiledConstructor = TryCreateConstructor(type);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetProperty(string name, out BinaryPropertySetterInfo? propertyInfo)
=> _properties.TryGetValue(name, out propertyInfo);
private static Func<object>? TryCreateConstructor(Type type)
{
if (type.IsAbstract) return null;
var ctor = type.GetConstructor(BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null);
if (ctor == null) return null;
var newExpr = Expression.New(ctor);
var convert = Expression.Convert(newExpr, typeof(object));
return Expression.Lambda<Func<object>>(convert).Compile();
}
}
internal sealed class BinaryPropertySetterInfo
{
private static readonly Func<object, object?> NullGetter = static _ => null;
private static readonly Action<object, object?> NullSetter = static (_, _) => { };
private readonly Func<object, object?> _getter;
private readonly Action<object, object?> _setter;
public string Name { get; }
public Type PropertyType { get; }
public bool IsComplexType { get; }
public bool IsCollection { get; }
public Type? ElementType { get; }
public bool IsIIdCollection { get; }
public Type? ElementIdType { get; }
public Func<object, object?>? ElementIdGetter { get; }
public BinaryPropertySetterInfo(PropertyInfo property)
{
Name = property.Name;
PropertyType = property.PropertyType;
IsCollection = IsCollectionType(PropertyType);
ElementType = IsCollection ? GetCollectionElementType(PropertyType) : null;
if (ElementType != null)
{
var elementIdInfo = GetIdInfo(ElementType);
IsIIdCollection = elementIdInfo.IsId;
ElementIdType = elementIdInfo.IdType;
if (IsIIdCollection)
{
var idProp = ElementType.GetProperty("Id", BindingFlags.Instance | BindingFlags.Public);
if (idProp != null)
{
ElementIdGetter = CreateCompiledGetter(ElementType, idProp);
}
}
}
IsComplexType = IsComplex(PropertyType);
_getter = CreateGetter(property);
_setter = CreateSetter(property);
}
public BinaryPropertySetterInfo(
string name,
Type propertyType,
bool isCollection,
Type? elementType,
Type? elementIdType,
Func<object, object?>? elementIdGetter,
Func<object, object?>? getter = null,
Action<object, object?>? setter = null)
{
Name = name;
PropertyType = propertyType;
IsCollection = isCollection;
ElementType = elementType;
ElementIdType = elementIdType;
ElementIdGetter = elementIdGetter;
IsIIdCollection = elementIdGetter != null && elementIdType != null;
IsComplexType = elementType != null ? IsComplex(elementType) : IsComplex(propertyType);
_getter = getter ?? NullGetter;
_setter = setter ?? NullSetter;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public object? GetValue(object target) => _getter(target);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SetValue(object target, object? value)
{
try
{
_setter(target, value);
}
catch (InvalidCastException ex)
{
var valueType = value?.GetType().FullName ?? "null";
var propType = PropertyType.FullName;
var targetTypeName = target.GetType().FullName;
var underlyingType = Nullable.GetUnderlyingType(PropertyType);
// Get actual value info for debugging
var valueInfo = value switch
{
null => "null",
int i => $"int:{i}",
long l => $"long:{l}",
DateTime dt => $"DateTime:{dt:O}",
string s => $"string:'{s}'",
_ => $"{value.GetType().Name}:{value}"
};
throw new InvalidCastException(
$"Cannot set property '{Name}' on type '{targetTypeName}' - " +
$"PropertyType: '{propType}', ValueType: '{valueType}', Value: {valueInfo}, " +
$"IsNullable: {underlyingType != null}, UnderlyingType: {underlyingType?.FullName ?? "N/A"}, " +
$"IsValueType: {PropertyType.IsValueType}, IsCollection: {IsCollection}, IsComplexType: {IsComplexType}",
ex);
}
}
private static bool IsCollectionType(Type type)
{
if (ReferenceEquals(type, StringType)) return false;
if (type.IsArray) return true;
return typeof(IEnumerable).IsAssignableFrom(type);
}
private static bool IsComplex(Type type)
{
var actualType = Nullable.GetUnderlyingType(type) ?? type;
return IsComplexType(actualType);
}
private static Func<object, object?> CreateGetter(PropertyInfo property)
{
var targetParam = Expression.Parameter(typeof(object), "target");
var castTarget = Expression.Convert(targetParam, property.DeclaringType!);
var propertyAccess = Expression.Property(castTarget, property);
var boxed = Expression.Convert(propertyAccess, typeof(object));
return Expression.Lambda<Func<object, object?>>(boxed, targetParam).Compile();
}
private static Action<object, object?> CreateSetter(PropertyInfo property)
{
var targetParam = Expression.Parameter(typeof(object), "target");
var valueParam = Expression.Parameter(typeof(object), "value");
var castTarget = Expression.Convert(targetParam, property.DeclaringType!);
var propertyAccess = Expression.Property(castTarget, property);
Expression castValue;
var propertyType = property.PropertyType;
var underlyingType = Nullable.GetUnderlyingType(propertyType);
if (underlyingType != null)
{
// Nullable value type: first unbox to underlying type, then convert to nullable
// This handles cases where we receive int but need to set int?
var unboxed = Expression.Unbox(valueParam, underlyingType);
castValue = Expression.Convert(unboxed, propertyType);
}
else if (propertyType.IsValueType)
{
// Non-nullable value type: use Unbox for proper unboxing
castValue = Expression.Unbox(valueParam, propertyType);
}
else
{
// Reference type: use TypeAs for safe casting (returns null if incompatible)
castValue = Expression.TypeAs(valueParam, propertyType);
}
var assign = Expression.Assign(propertyAccess, castValue);
return Expression.Lambda<Action<object, object?>>(assign, targetParam, valueParam).Compile();
}
}
}

View File

@ -1,4 +1,3 @@
using System.Buffers;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Frozen;
@ -8,9 +7,9 @@ using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using AyCode.Core.Helpers;
using static AyCode.Core.Extensions.JsonUtilities;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Extensions;
namespace AyCode.Core.Serializers.Binaries;
/// <summary>
/// Exception thrown when binary deserialization fails.
@ -39,7 +38,7 @@ public class AcBinaryDeserializationException : Exception
/// - Optimized with FrozenDictionary for type dispatch
/// - Zero-allocation hot paths using Span and MemoryMarshal
/// </summary>
public static class AcBinaryDeserializer
public static partial class AcBinaryDeserializer
{
private static readonly ConcurrentDictionary<Type, BinaryDeserializeTypeMetadata> TypeMetadataCache = new();
private static readonly ConcurrentDictionary<Type, TypeConversionInfo> TypeConversionCache = new();
@ -67,9 +66,10 @@ public static class AcBinaryDeserializer
RegisterReader(BinaryTypeCode.Float64, static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadDoubleUnsafe());
RegisterReader(BinaryTypeCode.Decimal, static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadDecimalUnsafe());
RegisterReader(BinaryTypeCode.Char, static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadCharUnsafe());
RegisterReader(BinaryTypeCode.String, static (ref BinaryDeserializationContext ctx, Type _, int _) => ReadAndInternString(ref ctx));
RegisterReader(BinaryTypeCode.String, static (ref BinaryDeserializationContext ctx, Type _, int _) => ReadPlainString(ref ctx));
RegisterReader(BinaryTypeCode.StringInterned, static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.GetInternedString((int)ctx.ReadVarUInt()));
RegisterReader(BinaryTypeCode.StringEmpty, static (ref BinaryDeserializationContext _, Type _, int _) => string.Empty);
RegisterReader(BinaryTypeCode.StringInternNew, static (ref BinaryDeserializationContext ctx, Type _, int _) => ReadAndRegisterInternedString(ref ctx));
RegisterReader(BinaryTypeCode.DateTime, static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadDateTimeUnsafe());
RegisterReader(BinaryTypeCode.DateTimeOffset, static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadDateTimeOffsetUnsafe());
RegisterReader(BinaryTypeCode.TimeSpan, static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadTimeSpanUnsafe());
@ -80,6 +80,25 @@ public static class AcBinaryDeserializer
RegisterReader(BinaryTypeCode.Array, ReadArray);
RegisterReader(BinaryTypeCode.Dictionary, ReadDictionary);
RegisterReader(BinaryTypeCode.ByteArray, static (ref BinaryDeserializationContext ctx, Type _, int _) => ReadByteArray(ref ctx));
// Register FixStr readers (34-65)
for (byte code = BinaryTypeCode.FixStrBase; code <= BinaryTypeCode.FixStrMax; code++)
{
var length = BinaryTypeCode.DecodeFixStrLength(code);
RegisterReader(code, CreateFixStrReader(length));
}
}
/// <summary>
/// Creates a reader for FixStr with the given length.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static TypeReader CreateFixStrReader(int length)
{
if (length == 0)
return static (ref BinaryDeserializationContext _, Type _, int _) => string.Empty;
return (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadStringUtf8(length);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@ -91,18 +110,29 @@ public static class AcBinaryDeserializer
/// Deserialize binary data to object of type T.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T? Deserialize<T>(byte[] data) => Deserialize<T>(data.AsSpan());
public static T? Deserialize<T>(byte[] data) => Deserialize<T>(data.AsSpan(), AcBinarySerializerOptions.Default);
/// <summary>
/// Deserialize binary data to object of type T with options.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T? Deserialize<T>(byte[] data, AcBinarySerializerOptions options) => Deserialize<T>(data.AsSpan(), options);
/// <summary>
/// Deserialize binary data to object of type T.
/// </summary>
public static T? Deserialize<T>(ReadOnlySpan<byte> data)
public static T? Deserialize<T>(ReadOnlySpan<byte> data) => Deserialize<T>(data, AcBinarySerializerOptions.Default);
/// <summary>
/// Deserialize binary data to object of type T with options.
/// </summary>
public static T? Deserialize<T>(ReadOnlySpan<byte> data, AcBinarySerializerOptions options)
{
if (data.Length == 0) return default;
if (data.Length == 1 && data[0] == BinaryTypeCode.Null) return default;
var targetType = typeof(T);
var context = new BinaryDeserializationContext(data);
var context = new BinaryDeserializationContext(data, options);
try
{
@ -125,12 +155,18 @@ public static class AcBinaryDeserializer
/// <summary>
/// Deserialize binary data to specified type.
/// </summary>
public static object? Deserialize(ReadOnlySpan<byte> data, Type targetType)
public static object? Deserialize(ReadOnlySpan<byte> data, Type targetType)
=> Deserialize(data, targetType, AcBinarySerializerOptions.Default);
/// <summary>
/// Deserialize binary data to specified type with options.
/// </summary>
public static object? Deserialize(ReadOnlySpan<byte> data, Type targetType, AcBinarySerializerOptions options)
{
if (data.Length == 0) return null;
if (data.Length == 1 && data[0] == BinaryTypeCode.Null) return null;
var context = new BinaryDeserializationContext(data);
var context = new BinaryDeserializationContext(data, options);
try
{
@ -153,19 +189,31 @@ public static class AcBinaryDeserializer
/// Populate existing object from binary data.
/// </summary>
public static void Populate<T>(byte[] data, T target) where T : class
=> Populate(data.AsSpan(), target);
=> Populate(data.AsSpan(), target, AcBinarySerializerOptions.Default);
/// <summary>
/// Populate existing object from binary data with options.
/// </summary>
public static void Populate<T>(byte[] data, T target, AcBinarySerializerOptions options) where T : class
=> Populate(data.AsSpan(), target, options);
/// <summary>
/// Populate existing object from binary data.
/// </summary>
public static void Populate<T>(ReadOnlySpan<byte> data, T target) where T : class
=> Populate(data, target, AcBinarySerializerOptions.Default);
/// <summary>
/// Populate existing object from binary data with options.
/// </summary>
public static void Populate<T>(ReadOnlySpan<byte> data, T target, AcBinarySerializerOptions options) where T : class
{
ArgumentNullException.ThrowIfNull(target);
if (data.Length == 0) return;
if (data.Length == 1 && data[0] == BinaryTypeCode.Null) return;
var targetType = target.GetType();
var context = new BinaryDeserializationContext(data);
var context = new BinaryDeserializationContext(data, options);
try
{
@ -205,13 +253,28 @@ public static class AcBinaryDeserializer
/// Populate with merge semantics for IId collections.
/// </summary>
public static void PopulateMerge<T>(ReadOnlySpan<byte> data, T target) where T : class
=> PopulateMerge(data, target, AcBinarySerializerOptions.Default);
/// <summary>
/// Populate with merge semantics for IId collections.
/// </summary>
/// <param name="data">Binary data to deserialize</param>
/// <param name="target">Target object to populate</param>
/// <param name="options">Optional serializer options. When RemoveOrphanedItems is true,
/// items in destination collections that have no matching Id in source will be removed.</param>
public static void PopulateMerge<T>(ReadOnlySpan<byte> data, T target, AcBinarySerializerOptions? options) where T : class
{
ArgumentNullException.ThrowIfNull(target);
if (data.Length == 0) return;
if (data.Length == 1 && data[0] == BinaryTypeCode.Null) return;
var opts = options ?? AcBinarySerializerOptions.Default;
var targetType = target.GetType();
var context = new BinaryDeserializationContext(data) { IsMergeMode = true };
var context = new BinaryDeserializationContext(data, opts)
{
IsMergeMode = true,
RemoveOrphanedItems = opts.RemoveOrphanedItems
};
try
{
@ -270,6 +333,13 @@ public static class AcBinaryDeserializer
return ConvertToTargetType(intValue, targetType);
}
// Handle FixStr (short strings with length in type code)
if (BinaryTypeCode.IsFixStr(typeCode))
{
var length = BinaryTypeCode.DecodeFixStrLength(typeCode);
return length == 0 ? string.Empty : context.ReadStringUtf8(length);
}
var reader = TypeReaders[typeCode];
if (reader != null)
{
@ -281,6 +351,30 @@ public static class AcBinaryDeserializer
context.Position, targetType);
}
/// <summary>
/// Sima string olvasása - NEM regisztrál az intern táblába.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string ReadPlainString(ref BinaryDeserializationContext context)
{
var length = (int)context.ReadVarUInt();
if (length == 0) return string.Empty;
return context.ReadStringUtf8(length);
}
/// <summary>
/// Új internált string olvasása és regisztrálása az intern táblába.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string ReadAndRegisterInternedString(ref BinaryDeserializationContext context)
{
var length = (int)context.ReadVarUInt();
if (length == 0) return string.Empty;
var str = context.ReadStringUtf8(length);
context.RegisterInternedString(str);
return str;
}
/// <summary>
/// Read a string and register it in the intern table for future references.
/// </summary>
@ -425,13 +519,17 @@ public static class AcBinaryDeserializer
{
var propertyCount = (int)context.ReadVarUInt();
var nextDepth = depth + 1;
var targetType = target.GetType();
var targetTypeName = targetType.Name;
for (int i = 0; i < propertyCount; i++)
{
var propertyNameStartPosition = context.Position;
string propertyName;
int propIndex = -1;
if (context.HasMetadata)
{
var propIndex = (int)context.ReadVarUInt();
propIndex = (int)context.ReadVarUInt();
propertyName = context.GetPropertyName(propIndex);
}
else
@ -449,11 +547,22 @@ public static class AcBinaryDeserializer
{
propertyName = string.Empty;
}
else if (typeCode == BinaryTypeCode.StringInternNew)
{
propertyName = ReadAndRegisterInternedString(ref context);
}
else if (BinaryTypeCode.IsFixStr(typeCode))
{
// FixStr: short string with length encoded in type code
var length = BinaryTypeCode.DecodeFixStrLength(typeCode);
propertyName = length == 0 ? string.Empty : context.ReadStringUtf8(length);
}
else
{
throw new AcBinaryDeserializationException(
$"Expected string for property name, got: {typeCode}",
context.Position, target.GetType());
$"Expected string for property name, got: {typeCode} (0x{typeCode:X2}) at position {propertyNameStartPosition}. " +
$"Target: {targetTypeName}, PropertyIndex: {i}/{propertyCount}, Depth: {depth}",
context.Position, targetType);
}
}
@ -492,8 +601,28 @@ public static class AcBinaryDeserializer
}
// Default: read value and set (for primitives, strings, null cases)
var value = ReadValue(ref context, propInfo.PropertyType, nextDepth);
propInfo.SetValue(target, value);
var positionBeforeRead = context.Position;
try
{
var value = ReadValue(ref context, propInfo.PropertyType, nextDepth);
propInfo.SetValue(target, value);
}
catch (InvalidCastException ex)
{
// Add context about which property and what byte code was at the read position
throw new AcBinaryDeserializationException(
$"Type mismatch for property '{propertyName}' (index {i}/{propertyCount}, headerIndex={propIndex}) on '{targetTypeName}'. " +
$"Expected type: '{propInfo.PropertyType.FullName}'. " +
$"PeekCode before read: {peekCode} (0x{peekCode:X2}). " +
$"Position before read: {positionBeforeRead}, current: {context.Position}. " +
$"Depth: {depth}. " +
$"Target type: {targetType.FullName}, Assembly: {targetType.Assembly.GetName().Name} v{targetType.Assembly.GetName().Version}. " +
$"All target properties: [{string.Join(", ", metadata.PropertiesArray.Select(p => $"{p.Name}:{p.PropertyType.Name}"))}]. " +
$"Error: {ex.Message}",
positionBeforeRead,
propInfo.PropertyType,
ex);
}
}
}
@ -557,10 +686,20 @@ public static class AcBinaryDeserializer
{
propertyName = string.Empty;
}
else if (typeCode == BinaryTypeCode.StringInternNew)
{
propertyName = ReadAndRegisterInternedString(ref context);
}
else if (BinaryTypeCode.IsFixStr(typeCode))
{
// FixStr: short string with length encoded in type code
var length = BinaryTypeCode.DecodeFixStrLength(typeCode);
propertyName = length == 0 ? string.Empty : context.ReadStringUtf8(length);
}
else
{
throw new AcBinaryDeserializationException(
$"Expected string for property name, got: {typeCode}",
$"Expected string for property name, got: {typeCode} (0x{typeCode:X2})",
context.Position, targetType);
}
}
@ -637,6 +776,11 @@ public static class AcBinaryDeserializer
var arrayCount = (int)context.ReadVarUInt();
var nextDepth = depth + 1;
var elementMetadata = GetTypeMetadata(elementType);
// Track which IDs we see in source (for orphan removal)
HashSet<object>? sourceIds = context.RemoveOrphanedItems && existingById != null
? new HashSet<object>(arrayCount)
: null;
for (int i = 0; i < arrayCount; i++)
{
@ -664,9 +808,12 @@ public static class AcBinaryDeserializer
PopulateObject(ref context, newItem, elementMetadata, nextDepth);
var itemId = idGetter(newItem);
if (itemId != null && !IsDefaultValue(itemId, idType) && existingById != null)
if (itemId != null && !IsDefaultValue(itemId, idType))
{
if (existingById.TryGetValue(itemId, out var existingItem))
// Track this ID as seen in source
sourceIds?.Add(itemId);
if (existingById != null && existingById.TryGetValue(itemId, out var existingItem))
{
// Copy properties to existing item
CopyProperties(newItem, existingItem, elementMetadata);
@ -676,6 +823,26 @@ public static class AcBinaryDeserializer
existingList.Add(newItem);
}
// Remove orphaned items (items in destination but not in source)
if (context.RemoveOrphanedItems && existingById != null && sourceIds != null)
{
// Find items to remove (those not in sourceIds)
var itemsToRemove = new List<object>();
foreach (var kvp in existingById)
{
if (!sourceIds.Contains(kvp.Key))
{
itemsToRemove.Add(kvp.Value);
}
}
// Remove orphaned items
foreach (var item in itemsToRemove)
{
existingList.Remove(item);
}
}
}
finally
{
@ -1064,6 +1231,15 @@ public static class AcBinaryDeserializer
if (BinaryTypeCode.IsTinyInt(typeCode)) return;
// Handle FixStr (short strings)
if (BinaryTypeCode.IsFixStr(typeCode))
{
var length = BinaryTypeCode.DecodeFixStrLength(typeCode);
if (length > 0)
context.Skip(length);
return;
}
switch (typeCode)
{
case BinaryTypeCode.True:
@ -1109,12 +1285,16 @@ public static class AcBinaryDeserializer
context.Skip(16);
return;
case BinaryTypeCode.String:
// CRITICAL FIX: Must register string in intern table even when skipping!
SkipAndInternString(ref context);
// Sima string - nem regisztrálunk
SkipPlainString(ref context);
return;
case BinaryTypeCode.StringInterned:
context.ReadVarUInt();
return;
case BinaryTypeCode.StringInternNew:
// Új internált string - regisztrálni kell még skip esetén is
SkipAndRegisterInternedString(ref context);
return;
case BinaryTypeCode.ByteArray:
var byteLen = (int)context.ReadVarUInt();
context.Skip(byteLen);
@ -1139,6 +1319,31 @@ public static class AcBinaryDeserializer
}
}
/// <summary>
/// Sima string kihagyása - NEM regisztrál.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void SkipPlainString(ref BinaryDeserializationContext context)
{
var byteLen = (int)context.ReadVarUInt();
if (byteLen > 0)
{
context.Skip(byteLen);
}
}
/// <summary>
/// Új internált string kihagyása - DE regisztrálni kell!
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void SkipAndRegisterInternedString(ref BinaryDeserializationContext context)
{
var byteLen = (int)context.ReadVarUInt();
if (byteLen == 0) return;
var str = context.ReadStringUtf8(byteLen);
context.RegisterInternedString(str);
}
/// <summary>
/// Skip a string but still register it in the intern table if it meets the length threshold.
/// </summary>
@ -1184,6 +1389,18 @@ public static class AcBinaryDeserializer
// Just read the index, no registration needed
context.ReadVarUInt();
}
else if (nameCode == BinaryTypeCode.StringInternNew)
{
// New interned string - must register even when skipping!
SkipAndRegisterInternedString(ref context);
}
else if (BinaryTypeCode.IsFixStr(nameCode))
{
// FixStr: short string, just skip the bytes
var length = BinaryTypeCode.DecodeFixStrLength(nameCode);
if (length > 0)
context.Skip(length);
}
// StringEmpty doesn't need any action
}
@ -1246,531 +1463,23 @@ public static class AcBinaryDeserializer
return Expression.Lambda<Func<object, object?>>(boxed, objParam).Compile();
}
internal sealed class BinaryDeserializeTypeMetadata
{
private readonly FrozenDictionary<string, BinaryPropertySetterInfo> _propertiesDict;
public BinaryPropertySetterInfo[] PropertiesArray { get; }
public Func<object>? CompiledConstructor { get; }
public BinaryDeserializeTypeMetadata(Type type)
{
var ctor = type.GetConstructor(Type.EmptyTypes);
if (ctor != null)
{
var newExpr = Expression.New(type);
var boxed = Expression.Convert(newExpr, typeof(object));
CompiledConstructor = Expression.Lambda<Func<object>>(boxed).Compile();
}
var allProps = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
var propsList = new List<PropertyInfo>();
foreach (var p in allProps)
{
if (!p.CanWrite || !p.CanRead || p.GetIndexParameters().Length != 0) continue;
if (HasJsonIgnoreAttribute(p)) continue;
propsList.Add(p);
}
var propInfos = new BinaryPropertySetterInfo[propsList.Count];
for (int i = 0; i < propsList.Count; i++)
{
propInfos[i] = new BinaryPropertySetterInfo(propsList[i], type);
}
PropertiesArray = propInfos;
var dict = new Dictionary<string, BinaryPropertySetterInfo>(propInfos.Length, StringComparer.OrdinalIgnoreCase);
foreach (var propInfo in propInfos)
{
dict[propInfo.Name] = propInfo;
}
_propertiesDict = FrozenDictionary.ToFrozenDictionary(dict, StringComparer.OrdinalIgnoreCase);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetProperty(string name, out BinaryPropertySetterInfo? propInfo)
=> _propertiesDict.TryGetValue(name, out propInfo);
}
internal sealed class BinaryPropertySetterInfo
{
public readonly string Name;
public readonly Type PropertyType;
public readonly Type UnderlyingType;
public readonly bool IsIIdCollection;
public readonly bool IsComplexType;
public readonly bool IsCollection;
public readonly Type? ElementType;
public readonly Type? ElementIdType;
public readonly Func<object, object?>? ElementIdGetter;
private readonly Action<object, object?> _setter;
private readonly Func<object, object?> _getter;
public BinaryPropertySetterInfo(PropertyInfo prop, Type declaringType)
{
Name = prop.Name;
PropertyType = prop.PropertyType;
UnderlyingType = Nullable.GetUnderlyingType(PropertyType) ?? PropertyType;
_setter = CreateCompiledSetter(declaringType, prop);
_getter = CreateCompiledGetter(declaringType, prop);
ElementType = GetCollectionElementType(PropertyType);
IsCollection = ElementType != null && ElementType != typeof(object) &&
typeof(IEnumerable).IsAssignableFrom(PropertyType) &&
!ReferenceEquals(PropertyType, StringType);
// Determine if this is a complex type that can be populated
IsComplexType = !PropertyType.IsPrimitive &&
!ReferenceEquals(PropertyType, StringType) &&
!PropertyType.IsEnum &&
!ReferenceEquals(PropertyType, GuidType) &&
!ReferenceEquals(PropertyType, DateTimeType) &&
!ReferenceEquals(PropertyType, DecimalType) &&
!ReferenceEquals(PropertyType, TimeSpanType) &&
!ReferenceEquals(PropertyType, DateTimeOffsetType) &&
Nullable.GetUnderlyingType(PropertyType) == null &&
!IsCollection;
if (IsCollection && ElementType != null)
{
var idInfo = GetIdInfo(ElementType);
if (idInfo.IsId)
{
IsIIdCollection = true;
ElementIdType = idInfo.IdType;
var idProp = ElementType.GetProperty("Id");
if (idProp != null)
ElementIdGetter = CreateCompiledGetter(ElementType, idProp);
}
}
}
// Constructor for manual creation (merge scenarios)
public BinaryPropertySetterInfo(string name, Type propertyType, bool isIIdCollection, Type? elementType, Type? elementIdType, Func<object, object?>? elementIdGetter)
{
Name = name;
PropertyType = propertyType;
UnderlyingType = Nullable.GetUnderlyingType(PropertyType) ?? PropertyType;
IsIIdCollection = isIIdCollection;
IsCollection = elementType != null;
IsComplexType = false;
ElementType = elementType;
ElementIdType = elementIdType;
ElementIdGetter = elementIdGetter;
_setter = (_, _) => { };
_getter = _ => null;
}
private static Action<object, object?> CreateCompiledSetter(Type declaringType, PropertyInfo prop)
{
var objParam = Expression.Parameter(typeof(object), "obj");
var valueParam = Expression.Parameter(typeof(object), "value");
var castObj = Expression.Convert(objParam, declaringType);
var castValue = Expression.Convert(valueParam, prop.PropertyType);
var propAccess = Expression.Property(castObj, prop);
var assign = Expression.Assign(propAccess, castValue);
return Expression.Lambda<Action<object, object?>>(assign, objParam, valueParam).Compile();
}
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 void SetValue(object target, object? value) => _setter(target, value);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public object? GetValue(object target) => _getter(target);
}
#endregion
#region Deserialization Context
// Implementation moved to AcBinaryDeserializer.BinaryDeserializeTypeMetadata.cs
}
/// <summary>
/// Optimized deserialization context using ref struct for zero allocation.
/// Uses MemoryMarshal for fast primitive reads.
/// </summary>
internal ref struct BinaryDeserializationContext
sealed class TypeConversionInfo
{
public Type UnderlyingType { get; }
public TypeCode TypeCode { get; }
public bool IsEnum { get; }
public TypeConversionInfo(Type underlyingType, TypeCode typeCode, bool isEnum)
{
private readonly ReadOnlySpan<byte> _data;
private int _position;
// Header info
public byte FormatVersion { get; private set; }
public bool HasMetadata { get; private set; }
public bool HasReferenceHandling { get; private set; }
/// <summary>
/// Minimum string length for interning. Must match serializer's MinStringInternLength.
/// Default: 4 (from AcBinarySerializerOptions)
/// </summary>
public byte MinStringInternLength { get; private set; }
// Property name table
private string[]? _propertyNames;
// Interned strings - dynamically built during deserialization
private List<string>? _internedStrings;
// Reference map
private Dictionary<int, object>? _references;
public bool IsMergeMode { get; set; }
public int Position => _position;
public bool IsAtEnd => _position >= _data.Length;
public BinaryDeserializationContext(ReadOnlySpan<byte> data)
{
_data = data;
_position = 0;
FormatVersion = 0;
HasMetadata = false;
HasReferenceHandling = true; // Assume true by default
MinStringInternLength = 4; // Default from AcBinarySerializerOptions
_propertyNames = null;
_internedStrings = null;
_references = null;
IsMergeMode = false;
}
public void ReadHeader()
{
if (_data.Length < 2) return;
FormatVersion = ReadByte();
var flags = ReadByte();
// Handle new flag-based header format (34+)
if (flags >= BinaryTypeCode.HeaderFlagsBase)
{
HasMetadata = (flags & BinaryTypeCode.HeaderFlag_Metadata) != 0;
HasReferenceHandling = (flags & BinaryTypeCode.HeaderFlag_ReferenceHandling) != 0;
}
else
{
// Legacy format: MetadataHeader (32) or NoMetadataHeader (33)
// These always implied HasReferenceHandling = true
HasMetadata = flags == BinaryTypeCode.MetadataHeader;
HasReferenceHandling = true;
}
if (HasMetadata)
{
// Read property names
var propCount = (int)ReadVarUInt();
if (propCount > 0)
{
_propertyNames = new string[propCount];
for (int i = 0; i < propCount; i++)
{
var len = (int)ReadVarUInt();
_propertyNames[i] = ReadStringUtf8(len);
}
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public byte ReadByte()
{
if (_position >= _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
return _data[_position++];
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public byte PeekByte()
{
if (_position >= _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
return _data[_position];
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Skip(int count)
{
_position += count;
if (_position > _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public byte[] ReadBytes(int count)
{
if (_position + count > _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
var result = _data.Slice(_position, count).ToArray();
_position += count;
return result;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int ReadVarInt()
{
var encoded = ReadVarUInt();
// ZigZag decode
return (int)((encoded >> 1) ^ -(encoded & 1));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public uint ReadVarUInt()
{
uint result = 0;
int shift = 0;
while (true)
{
var b = ReadByte();
result |= (uint)(b & 0x7F) << shift;
if ((b & 0x80) == 0) break;
shift += 7;
if (shift > 28)
throw new AcBinaryDeserializationException("Invalid VarInt", _position);
}
return result;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public long ReadVarLong()
{
var encoded = ReadVarULong();
// ZigZag decode
return (long)((encoded >> 1) ^ (0 - (encoded & 1)));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ulong ReadVarULong()
{
ulong result = 0;
int shift = 0;
while (true)
{
var b = ReadByte();
result |= (ulong)(b & 0x7F) << shift;
if ((b & 0x80) == 0) break;
shift += 7;
if (shift > 63)
throw new AcBinaryDeserializationException("Invalid VarLong", _position);
}
return result;
}
/// <summary>
/// Optimized Int16 read using direct memory access.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public short ReadInt16Unsafe()
{
if (_position + 2 > _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
var result = Unsafe.ReadUnaligned<short>(ref Unsafe.AsRef(in _data[_position]));
_position += 2;
return result;
}
/// <summary>
/// Optimized UInt16 read using direct memory access.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ushort ReadUInt16Unsafe()
{
if (_position + 2 > _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
var result = Unsafe.ReadUnaligned<ushort>(ref Unsafe.AsRef(in _data[_position]));
_position += 2;
return result;
}
/// <summary>
/// Optimized float read using direct memory access.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ReadSingleUnsafe()
{
if (_position + 4 > _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
var result = Unsafe.ReadUnaligned<float>(ref Unsafe.AsRef(in _data[_position]));
_position += 4;
return result;
}
/// <summary>
/// Optimized double read using direct memory access.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public double ReadDoubleUnsafe()
{
if (_position + 8 > _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
var result = Unsafe.ReadUnaligned<double>(ref Unsafe.AsRef(in _data[_position]));
_position += 8;
return result;
}
/// <summary>
/// Optimized decimal read using direct memory copy.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public decimal ReadDecimalUnsafe()
{
if (_position + 16 > _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
Span<int> bits = stackalloc int[4];
MemoryMarshal.Cast<byte, int>(_data.Slice(_position, 16)).CopyTo(bits);
_position += 16;
return new decimal(bits);
}
/// <summary>
/// Optimized char read.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public char ReadCharUnsafe()
{
if (_position + 2 > _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
var result = Unsafe.ReadUnaligned<char>(ref Unsafe.AsRef(in _data[_position]));
_position += 2;
return result;
}
/// <summary>
/// Optimized DateTime read.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public DateTime ReadDateTimeUnsafe()
{
if (_position + 9 > _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
var ticks = Unsafe.ReadUnaligned<long>(ref Unsafe.AsRef(in _data[_position]));
var kind = (DateTimeKind)_data[_position + 8];
_position += 9;
return new DateTime(ticks, kind);
}
/// <summary>
/// Optimized DateTimeOffset read.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public DateTimeOffset ReadDateTimeOffsetUnsafe()
{
if (_position + 10 > _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
var utcTicks = Unsafe.ReadUnaligned<long>(ref Unsafe.AsRef(in _data[_position]));
var offsetMinutes = Unsafe.ReadUnaligned<short>(ref Unsafe.AsRef(in _data[_position + 8]));
_position += 10;
var offset = TimeSpan.FromMinutes(offsetMinutes);
var localTicks = utcTicks + offset.Ticks;
return new DateTimeOffset(localTicks, offset);
}
/// <summary>
/// Optimized TimeSpan read.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TimeSpan ReadTimeSpanUnsafe()
{
if (_position + 8 > _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
var ticks = Unsafe.ReadUnaligned<long>(ref Unsafe.AsRef(in _data[_position]));
_position += 8;
return new TimeSpan(ticks);
}
/// <summary>
/// Optimized Guid read.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Guid ReadGuidUnsafe()
{
if (_position + 16 > _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
var result = new Guid(_data.Slice(_position, 16));
_position += 16;
return result;
}
/// <summary>
/// Optimized string read using UTF8 span decoding.
/// Uses String.Create to decode directly into the target string buffer to avoid intermediate allocations.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public string ReadStringUtf8(int byteCount)
{
if (_position + byteCount > _data.Length)
throw new AcBinaryDeserializationException("Unexpected end of data", _position);
var src = _data.Slice(_position, byteCount);
var result = Utf8NoBom.GetString(src);
_position += byteCount;
return result;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public string GetPropertyName(int index)
{
if (_propertyNames == null || index < 0 || index >= _propertyNames.Length)
throw new AcBinaryDeserializationException($"Invalid property name index: {index}", _position);
return _propertyNames[index];
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void RegisterInternedString(string value)
{
_internedStrings ??= new List<string>(16);
_internedStrings.Add(value);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public string GetInternedString(int index)
{
if (_internedStrings == null || index < 0 || index >= _internedStrings.Count)
throw new AcBinaryDeserializationException($"Invalid interned string index: {index}. Interned strings count: {_internedStrings?.Count ?? 0}", _position);
return _internedStrings[index];
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void RegisterObject(int refId, object obj)
{
_references ??= new Dictionary<int, object>();
_references[refId] = obj;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public object? GetReferencedObject(int refId)
{
if (_references != null && _references.TryGetValue(refId, out var obj))
return obj;
return null;
}
}
#endregion
private sealed class TypeConversionInfo
{
public Type UnderlyingType { get; }
public TypeCode TypeCode { get; }
public bool IsEnum { get; }
public TypeConversionInfo(Type underlyingType, TypeCode typeCode, bool isEnum)
{
UnderlyingType = underlyingType;
TypeCode = typeCode;
IsEnum = isEnum;
}
UnderlyingType = underlyingType;
TypeCode = typeCode;
IsEnum = isEnum;
}
}
// Implementation moved to AcBinaryDeserializer.TypeConversionInfo.cs

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,50 @@
using System;
using System.Buffers;
namespace AyCode.Core.Serializers.Binaries;
public static partial class AcBinarySerializer
{
public sealed class BinarySerializationResult : IDisposable
{
private readonly bool _pooled;
private bool _disposed;
internal BinarySerializationResult(byte[] buffer, int length, bool pooled)
{
Buffer = buffer;
Length = length;
_pooled = pooled;
}
public byte[] Buffer { get; }
public int Length { get; }
public ReadOnlySpan<byte> Span => Buffer.AsSpan(0, Length);
public ReadOnlyMemory<byte> Memory => new(Buffer, 0, Length);
public byte[] ToArray()
{
var result = GC.AllocateUninitializedArray<byte>(Length);
Buffer.AsSpan(0, Length).CopyTo(result);
return result;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_pooled)
{
ArrayPool<byte>.Shared.Return(Buffer);
}
}
internal static BinarySerializationResult FromImmutable(byte[] buffer)
=> new(buffer, buffer.Length, pooled: false);
}
}

View File

@ -0,0 +1,194 @@
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Binaries;
public static partial class AcBinarySerializer
{
internal sealed class BinaryTypeMetadata
{
public BinaryPropertyAccessor[] Properties { get; }
public BinaryTypeMetadata(Type type)
{
Properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead &&
p.GetIndexParameters().Length == 0 &&
!HasJsonIgnoreAttribute(p))
.Select(p => new BinaryPropertyAccessor(p))
.ToArray();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static BinaryTypeMetadata GetTypeMetadata(Type type)
=> TypeMetadataCache.GetOrAdd(type, static t => new BinaryTypeMetadata(t));
}
internal sealed class BinaryPropertyAccessor
{
public readonly string Name;
public readonly byte[] NameUtf8;
public readonly Type PropertyType;
public readonly TypeCode TypeCode;
public readonly Type DeclaringType;
private readonly Func<object, object?> _objectGetter;
private readonly Delegate? _typedGetter;
private readonly PropertyAccessorType _accessorType;
/// <summary>
/// Cached property name index for metadata mode. Set by context during registration.
/// -1 means not yet cached.
/// </summary>
internal int CachedPropertyNameIndex = -1;
public BinaryPropertyAccessor(PropertyInfo prop)
{
Name = prop.Name;
NameUtf8 = Encoding.UTF8.GetBytes(prop.Name);
DeclaringType = prop.DeclaringType!;
PropertyType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
TypeCode = Type.GetTypeCode(PropertyType);
(_typedGetter, _accessorType) = CreateTypedGetter(DeclaringType, prop);
_objectGetter = CreateObjectGetter(DeclaringType, prop);
}
public PropertyAccessorType AccessorType => _accessorType;
public Func<object, object?> ObjectGetter => _objectGetter;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public object? GetValue(object obj) => _objectGetter(obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int GetInt32(object obj) => ((Func<object, int>)_typedGetter!)(obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public long GetInt64(object obj) => ((Func<object, long>)_typedGetter!)(obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool GetBoolean(object obj) => ((Func<object, bool>)_typedGetter!)(obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public double GetDouble(object obj) => ((Func<object, double>)_typedGetter!)(obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float GetSingle(object obj) => ((Func<object, float>)_typedGetter!)(obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public decimal GetDecimal(object obj) => ((Func<object, decimal>)_typedGetter!)(obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public DateTime GetDateTime(object obj) => ((Func<object, DateTime>)_typedGetter!)(obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public byte GetByte(object obj) => ((Func<object, byte>)_typedGetter!)(obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public short GetInt16(object obj) => ((Func<object, short>)_typedGetter!)(obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ushort GetUInt16(object obj) => ((Func<object, ushort>)_typedGetter!)(obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public uint GetUInt32(object obj) => ((Func<object, uint>)_typedGetter!)(obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ulong GetUInt64(object obj) => ((Func<object, ulong>)_typedGetter!)(obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Guid GetGuid(object obj) => ((Func<object, Guid>)_typedGetter!)(obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int GetEnumAsInt32(object obj) => ((Func<object, int>)_typedGetter!)(obj);
private static (Delegate?, PropertyAccessorType) CreateTypedGetter(Type declaringType, PropertyInfo prop)
{
var propType = prop.PropertyType;
var underlying = Nullable.GetUnderlyingType(propType);
if (underlying != null)
{
return (null, PropertyAccessorType.Object);
}
if (propType.IsEnum)
{
return (CreateEnumGetter(declaringType, prop), PropertyAccessorType.Enum);
}
if (ReferenceEquals(propType, GuidType))
{
return (CreateTypedGetterDelegate<Guid>(declaringType, prop), PropertyAccessorType.Guid);
}
var typeCode = Type.GetTypeCode(propType);
return typeCode switch
{
TypeCode.Int32 => (CreateTypedGetterDelegate<int>(declaringType, prop), PropertyAccessorType.Int32),
TypeCode.Int64 => (CreateTypedGetterDelegate<long>(declaringType, prop), PropertyAccessorType.Int64),
TypeCode.Boolean => (CreateTypedGetterDelegate<bool>(declaringType, prop), PropertyAccessorType.Boolean),
TypeCode.Double => (CreateTypedGetterDelegate<double>(declaringType, prop), PropertyAccessorType.Double),
TypeCode.Single => (CreateTypedGetterDelegate<float>(declaringType, prop), PropertyAccessorType.Single),
TypeCode.Decimal => (CreateTypedGetterDelegate<decimal>(declaringType, prop), PropertyAccessorType.Decimal),
TypeCode.DateTime => (CreateTypedGetterDelegate<DateTime>(declaringType, prop), PropertyAccessorType.DateTime),
TypeCode.Byte => (CreateTypedGetterDelegate<byte>(declaringType, prop), PropertyAccessorType.Byte),
TypeCode.Int16 => (CreateTypedGetterDelegate<short>(declaringType, prop), PropertyAccessorType.Int16),
TypeCode.UInt16 => (CreateTypedGetterDelegate<ushort>(declaringType, prop), PropertyAccessorType.UInt16),
TypeCode.UInt32 => (CreateTypedGetterDelegate<uint>(declaringType, prop), PropertyAccessorType.UInt32),
TypeCode.UInt64 => (CreateTypedGetterDelegate<ulong>(declaringType, prop), PropertyAccessorType.UInt64),
_ => (null, PropertyAccessorType.Object)
};
}
private static Delegate CreateEnumGetter(Type declaringType, PropertyInfo prop)
{
var objParam = Expression.Parameter(typeof(object), "obj");
var castExpr = Expression.Convert(objParam, declaringType);
var propAccess = Expression.Property(castExpr, prop);
var convertToInt = Expression.Convert(propAccess, typeof(int));
return Expression.Lambda<Func<object, int>>(convertToInt, objParam).Compile();
}
private static Func<object, TProperty> CreateTypedGetterDelegate<TProperty>(Type declaringType, PropertyInfo prop)
{
var objParam = Expression.Parameter(typeof(object), "obj");
var castExpr = Expression.Convert(objParam, declaringType);
var propAccess = Expression.Property(castExpr, prop);
var convertExpr = Expression.Convert(propAccess, typeof(TProperty));
return Expression.Lambda<Func<object, TProperty>>(convertExpr, objParam).Compile();
}
private static Func<object, object?> CreateObjectGetter(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();
}
}
internal enum PropertyAccessorType : byte
{
Object = 0,
Int32,
Int64,
Boolean,
Double,
Single,
Decimal,
DateTime,
Byte,
Int16,
UInt16,
UInt32,
UInt64,
Guid,
Enum
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,8 @@
using System.Runtime.CompilerServices;
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Jsons;
namespace AyCode.Core.Extensions;
namespace AyCode.Core.Serializers.Binaries;
/// <summary>
/// Options for AcBinarySerializer and AcBinaryDeserializer.
@ -8,6 +10,11 @@ namespace AyCode.Core.Extensions;
/// </summary>
public sealed class AcBinarySerializerOptions : AcSerializerOptions
{
/// <summary>
/// Cached platform detection - true if running in WebAssembly/Browser environment.
/// </summary>
private static readonly bool DetectedIsWasm = OperatingSystem.IsBrowser();
public override AcSerializerType SerializerType { get; init; } = AcSerializerType.Binary;
/// <summary>
@ -37,9 +44,42 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions
public static readonly AcBinarySerializerOptions ShallowCopy = new()
{
MaxDepth = 0,
UseMetadata = false,
UseStringInterning = false,
UseReferenceHandling = false
};
/// <summary>
/// Options optimized for WASM environment with string caching enabled.
/// </summary>
public static readonly AcBinarySerializerOptions WasmOptimized = new()
{
IsWasm = true,
UseStringCaching = true
};
/// <summary>
/// Whether running in WebAssembly/Browser environment.
/// When true, enables WASM-specific optimizations like string caching.
/// Default: auto-detected via OperatingSystem.IsBrowser()
/// </summary>
public bool IsWasm { get; init; } = DetectedIsWasm;
/// <summary>
/// Whether to cache short strings during deserialization to reduce allocations.
/// Most beneficial in WASM where GC is expensive.
/// Auto-enabled when IsWasm is true, can be overridden.
/// Default: follows IsWasm setting
/// </summary>
public bool UseStringCaching { get; init; } = DetectedIsWasm;
/// <summary>
/// Maximum string length to cache when UseStringCaching is enabled.
/// Longer strings are not cached to avoid memory bloat.
/// Default: 64 characters
/// </summary>
public int MaxCachedStringLength { get; init; } = 64;
/// <summary>
/// Whether to include metadata header with property names.
/// When enabled, property names are stored once and referenced by index.
@ -69,6 +109,20 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions
/// </summary>
public int InitialBufferCapacity { get; init; } = 4096;
/// <summary>
/// Optional property-level filter invoked before metadata registration and serialization.
/// Return false to exclude the property from the payload.
/// </summary>
public BinaryPropertyFilter? PropertyFilter { get; init; }
/// <summary>
/// When true, PopulateMerge will remove items from destination collections
/// that have no matching Id in the source data.
/// Only applies to IId collections during merge operations.
/// Default: false (orphaned items are kept)
/// </summary>
public bool RemoveOrphanedItems { get; init; } = false;
/// <summary>
/// Creates options with specified max depth.
/// </summary>
@ -117,6 +171,7 @@ internal static class BinaryTypeCode
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
public const byte StringInternNew = 19; // New interned string - full content + register in table
// Date/Time types (20-23)
public const byte DateTime = 20;
@ -143,18 +198,22 @@ internal static class BinaryTypeCode
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)
// FixStr range: 34-65 (32 values for strings 0-31 bytes)
// FixStr encoding: FixStrBase + length (0-31)
// This saves 1 byte for short strings by combining type + length in single byte
public const byte FixStrBase = 34; // Base value for FixStr (0xA0 in MessagePack style, but we use 34)
public const byte FixStrMax = 65; // FixStrBase + 31 = maximum FixStr code
// New flag-based header markers (48+) - moved to after FixStr range
// Note: FixStr range 34-65 overlaps with old HeaderFlagsBase, so headers use version byte prefix
public const byte HeaderFlagsBase = 48; // Base value for flag-based headers (0x30)
public const byte HeaderFlag_Metadata = 0x01;
public const byte HeaderFlag_ReferenceHandling = 0x02;
public const byte HeaderFlag_StringInternTable = 0x04; // String intern pool preloaded in header
// 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
public const byte Int32Tiny = 192; // -16 to 63 stored in single byte (value = code - 192 - 16)
public const byte Int32TinyMax = 255; // Upper bound for tiny int (192 + 64 - 1 = 255)
/// <summary>
/// Check if type code represents a reference (string or object).
@ -162,11 +221,35 @@ internal static class BinaryTypeCode
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsReference(byte code) => code == StringInterned || code == ObjectRef;
/// <summary>
/// Check if type code is a FixStr (short string with length encoded in type code).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsFixStr(byte code) => code >= FixStrBase && code <= FixStrMax;
/// <summary>
/// Decode FixStr length from type code.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int DecodeFixStrLength(byte code) => code - FixStrBase;
/// <summary>
/// Encode FixStr type code for given byte length (0-31).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte EncodeFixStr(int byteLength) => (byte)(FixStrBase + byteLength);
/// <summary>
/// Check if byte length can be encoded as FixStr.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool CanEncodeAsFixStr(int byteLength) => byteLength >= 0 && byteLength <= 31;
/// <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;
public static bool IsTinyInt(byte code) => code >= Int32Tiny;
/// <summary>
/// Decode tiny int value from type code.
@ -175,13 +258,14 @@ internal static class BinaryTypeCode
public static int DecodeTinyInt(byte code) => code - Int32Tiny - 16;
/// <summary>
/// Encode small int value (-16 to 111) as type code.
/// Encode small int value (-16 to 47) 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)
// Range: -16 to 47 (64 values total, fitting in 192-255)
if (value >= -16 && value <= 47)
{
code = (byte)(value + 16 + Int32Tiny);
return true;
@ -190,3 +274,61 @@ internal static class BinaryTypeCode
return false;
}
}
/// <summary>
/// Delegate used to decide whether a property should be serialized.
/// </summary>
public delegate bool BinaryPropertyFilter(in BinaryPropertyFilterContext context);
/// <summary>
/// Provides property metadata and lazy value access for property filter evaluations.
/// </summary>
public readonly struct BinaryPropertyFilterContext
{
private readonly object? _instance;
private readonly Func<object, object?>? _valueGetter;
internal BinaryPropertyFilterContext(object? instance, Type declaringType, string propertyName, Type propertyType, Func<object, object?>? valueGetter)
{
_instance = instance;
DeclaringType = declaringType;
PropertyName = propertyName;
PropertyType = propertyType;
_valueGetter = valueGetter;
}
/// <summary>
/// Gets the declaring type of the property.
/// </summary>
public Type DeclaringType { get; }
/// <summary>
/// Gets the property name.
/// </summary>
public string PropertyName { get; }
/// <summary>
/// Gets the property type.
/// </summary>
public Type PropertyType { get; }
/// <summary>
/// Gets the instance being serialized when available. Null during metadata registration.
/// </summary>
public object? Instance => _instance;
/// <summary>
/// Indicates whether the filter is invoked during metadata registration (when no instance is available).
/// </summary>
public bool IsMetadataPhase => _instance is null;
/// <summary>
/// Lazily obtains the current property value. Returns null when invoked during metadata registration.
/// </summary>
public object? GetValue()
{
if (_instance == null || _valueGetter == null)
return null;
return _valueGetter(_instance);
}
}

View File

@ -1,4 +1,3 @@
using System.Buffers;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Frozen;
@ -9,11 +8,9 @@ using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using AyCode.Core.Helpers;
using AyCode.Core.Interfaces;
using Newtonsoft.Json;
using static AyCode.Core.Extensions.JsonUtilities;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Extensions;
namespace AyCode.Core.Serializers.Jsons;
/// <summary>
/// Exception thrown when JSON deserialization fails.

View File

@ -7,11 +7,9 @@ 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;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Extensions;
namespace AyCode.Core.Serializers.Jsons;
/// <summary>
/// High-performance custom JSON serializer optimized for IId&lt;T&gt; reference handling.

View File

@ -1,4 +1,4 @@
namespace AyCode.Core.Extensions;
namespace AyCode.Core.Serializers.Jsons;
public enum AcSerializerType : byte
{

View File

@ -2,13 +2,14 @@
using System.Collections.Concurrent;
using System.Reflection;
using System.Runtime.CompilerServices;
using AyCode.Core.Extensions;
using AyCode.Core.Interfaces;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using static AyCode.Core.Extensions.JsonUtilities;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Extensions;
namespace AyCode.Core.Serializers.Jsons;
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class JsonNoMergeCollectionAttribute : Attribute { }

View File

@ -1,8 +1,11 @@
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs;
using MessagePack.Resolvers;
using AyCode.Core.Tests.Serialization;
namespace AyCode.Services.Server.Tests.SignalRs;
@ -926,185 +929,68 @@ public abstract class SignalRClientToHubTestBase
#endregion
#region Property Mismatch Tests (Server has more properties than Client - tests SkipValue)
#region StockTaking Production Bug Reproduction
/// <summary>
/// REGRESSION TEST: Tests the case where server sends a DTO with more properties than the client knows about.
/// Bug: "Invalid interned string index: 15. Interned strings count: 12"
/// Root cause: When deserializing, unknown properties are skipped via SkipValue(), but the skipped
/// string values were not being registered in the intern table, causing index mismatch for later StringInterned references.
/// CRITICAL PRODUCTION BUG TEST: Reproduces the exact GetStockTakings(false) scenario.
/// Bug: "Type mismatch for property 'Created' (index 3/5) on 'StockTaking'"
///
/// This test simulates the production bug where CustomerDto had properties on server
/// that the client didn't have defined.
/// Root cause hypothesis: When IsClosed=false, the serializer skips it (default value optimization),
/// but the first item has IsClosed=false while others have IsClosed=true.
/// This may cause property index mismatch between server and client.
/// Uses the REAL StockTaking model from StockTakingTestModels.cs
/// </summary>
[TestMethod]
public async Task PropertyMismatch_ServerHasMoreProperties_DeserializesCorrectly()
public async Task GetStockTakings_WithNullItems_RoundTrip()
{
// Arrange: Create "server" DTO with many properties
var serverDto = new ServerCustomerDto
{
Id = 1,
FirstName = "John",
LastName = "Smith",
Email = "john.smith@example.com",
Phone = "+1-555-1234",
Address = "123 Main Street",
City = "New York",
Country = "USA",
PostalCode = "10001",
Company = "Acme Corp",
Department = "Engineering",
Notes = "VIP customer with special requirements",
Status = TestStatus.Active,
IsVerified = true,
LoginCount = 42,
Balance = 1234.56m
};
// Simulate production: loadRelations = false
var result = await _client.PostDataAsync<bool, List<StockTaking>>(
TestSignalRTags.GetStockTakings, false);
// Act: Send server DTO, receive client DTO (fewer properties)
// This simulates the real bug scenario
var result = await _client.PostDataAsync<ServerCustomerDto, ClientCustomerDto>(
TestSignalRTags.PropertyMismatchParam, serverDto);
// Assert: Client should receive only the properties it knows about
Assert.IsNotNull(result, "Result should not be null - deserialization should succeed even with unknown properties");
Assert.AreEqual(1, result.Id);
Assert.AreEqual("John", result.FirstName);
Assert.AreEqual("Smith", result.LastName);
}
/// <summary>
/// REGRESSION TEST: Tests a list of DTOs with property mismatch.
/// This more closely simulates the production bug with GetMeasuringUsers returning List&lt;CustomerDto&gt;.
/// </summary>
[TestMethod]
public async Task PropertyMismatch_ListOfDtos_WithManyProperties_DeserializesCorrectly()
{
// Arrange: Create list of "server" DTOs with many string properties
var serverDtos = Enumerable.Range(0, 25).Select(i => new ServerCustomerDto
{
Id = i,
FirstName = $"FirstName_{i % 10}", // 10 unique values (will be interned)
LastName = $"LastName_{i % 8}", // 8 unique values
Email = $"user{i}@example.com",
Phone = $"+1-555-{i:D4}",
Address = $"Address_{i % 5}", // 5 unique values
City = i % 3 == 0 ? "New York" : i % 3 == 1 ? "Los Angeles" : "Chicago",
Country = "USA",
PostalCode = $"{10000 + i}",
Company = $"Company_{i % 6}", // 6 unique values
Department = i % 4 == 0 ? "Engineering" : i % 4 == 1 ? "Sales" : i % 4 == 2 ? "Marketing" : "Support",
Notes = $"Notes for customer {i}",
Status = (TestStatus)(i % 5),
IsVerified = i % 2 == 0,
LoginCount = i * 10,
Balance = i * 100.50m
}).ToList();
// Act: Send list of server DTOs, receive list of client DTOs
var result = await _client.PostDataAsync<List<ServerCustomerDto>, List<ClientCustomerDto>>(
TestSignalRTags.PropertyMismatchListParam, serverDtos);
// Assert
Assert.IsNotNull(result, "Result should not be null");
Assert.AreEqual(serverDtos.Count, result.Count, $"Expected {serverDtos.Count} items");
Assert.AreEqual(5, result.Count, "Should have 5 StockTakings from production DB");
for (int i = 0; i < serverDtos.Count; i++)
// Verify first item (IsClosed = false - this is where the bug occurs!)
var first = result[0];
Assert.AreEqual(7, first.Id, "First Id should be 7");
Assert.AreEqual(6, first.Creator,
$"First Creator should be 6, got {first.Creator}. " +
"If this is a very large number, DateTime bytes were interpreted as int!");
Assert.IsFalse(first.IsClosed, "First IsClosed should be false");
Assert.IsNull(first.StockTakingItems, "StockTakingItems should be null when loadRelations=false");
// Verify second item (IsClosed = true)
var second = result[1];
Assert.AreEqual(6, second.Id, "Second Id should be 6");
Assert.AreEqual(6, second.Creator, "Second Creator should be 6");
Assert.IsTrue(second.IsClosed, "Second IsClosed should be true");
// Verify all items have correct Creator = 6
foreach (var item in result)
{
Assert.AreEqual(serverDtos[i].Id, result[i].Id, $"Id mismatch at index {i}");
Assert.AreEqual(serverDtos[i].FirstName, result[i].FirstName,
$"FirstName mismatch at index {i}: expected '{serverDtos[i].FirstName}', got '{result[i].FirstName}'");
Assert.AreEqual(serverDtos[i].LastName, result[i].LastName,
$"LastName mismatch at index {i}: expected '{serverDtos[i].LastName}', got '{result[i].LastName}'");
Assert.AreEqual(6, item.Creator,
$"StockTaking Id={item.Id}: Creator should be 6, got {item.Creator}");
}
}
/// <summary>
/// REGRESSION TEST: Tests nested objects being skipped when client doesn't know about them.
/// Test with loadRelations = true (StockTakingItems is empty list, not null).
/// </summary>
[TestMethod]
public async Task PropertyMismatch_NestedObjectsSkipped_DeserializesCorrectly()
public async Task GetStockTakings_WithEmptyItems_RoundTrip()
{
// Arrange: Server order with nested customer object
var serverOrder = new ServerOrderWithExtras
var result = await _client.PostDataAsync<bool, List<StockTaking>>(
TestSignalRTags.GetStockTakings, true);
Assert.IsNotNull(result, "Result should not be null");
Assert.AreEqual(5, result.Count, "Should have 5 StockTakings");
foreach (var item in result)
{
Id = 100,
OrderNumber = "ORD-2024-001",
TotalAmount = 999.99m,
Customer = new ServerCustomerDto
{
Id = 1,
FirstName = "John",
LastName = "Doe",
Email = "john@example.com",
Phone = "+1-555-0001"
},
RelatedCustomers =
[
new ServerCustomerDto { Id = 2, FirstName = "Jane", LastName = "Smith", Email = "jane@example.com" },
new ServerCustomerDto { Id = 3, FirstName = "Bob", LastName = "Wilson", Email = "bob@example.com" }
],
InternalNotes = "Priority processing required",
ProcessingCode = "RUSH-001"
};
// Act: Send server order, receive simplified client order
var result = await _client.PostDataAsync<ServerOrderWithExtras, ClientOrderSimple>(
TestSignalRTags.PropertyMismatchNestedParam, serverOrder);
// Assert: Client should receive only basic order info
Assert.IsNotNull(result);
Assert.AreEqual(100, result.Id);
Assert.AreEqual("ORD-2024-001", result.OrderNumber);
Assert.AreEqual(999.99m, result.TotalAmount);
}
/// <summary>
/// REGRESSION TEST: Large list with nested objects being skipped.
/// This is the most comprehensive test for the SkipValue string interning bug.
/// </summary>
[TestMethod]
public async Task PropertyMismatch_LargeListWithNestedObjects_DeserializesCorrectly()
{
// Arrange: Create 50 orders with nested customers
var serverOrders = Enumerable.Range(0, 50).Select(i => new ServerOrderWithExtras
{
Id = i,
OrderNumber = $"ORD-{i:D4}",
TotalAmount = i * 100.50m,
Customer = new ServerCustomerDto
{
Id = i * 100,
FirstName = $"Customer_{i % 10}",
LastName = $"LastName_{i % 8}",
Email = $"customer{i}@example.com",
Company = $"Company_{i % 5}"
},
RelatedCustomers = Enumerable.Range(0, i % 3 + 1).Select(j => new ServerCustomerDto
{
Id = i * 100 + j,
FirstName = $"Related_{j}",
LastName = $"Contact_{i % 4}",
Email = $"related{i}_{j}@example.com"
}).ToList(),
InternalNotes = $"Notes for order {i}",
ProcessingCode = $"CODE-{i % 10}"
}).ToList();
// Act
var result = await _client.PostDataAsync<List<ServerOrderWithExtras>, List<ClientOrderSimple>>(
TestSignalRTags.PropertyMismatchNestedListParam, serverOrders);
// Assert
Assert.IsNotNull(result, "Result should not be null - SkipValue should correctly handle unknown nested objects");
Assert.AreEqual(serverOrders.Count, result.Count);
for (int i = 0; i < serverOrders.Count; i++)
{
Assert.AreEqual(serverOrders[i].Id, result[i].Id, $"Id mismatch at index {i}");
Assert.AreEqual(serverOrders[i].OrderNumber, result[i].OrderNumber,
$"OrderNumber mismatch at index {i}: expected '{serverOrders[i].OrderNumber}', got '{result[i].OrderNumber}'");
Assert.AreEqual(serverOrders[i].TotalAmount, result[i].TotalAmount, $"TotalAmount mismatch at index {i}");
Assert.AreEqual(6, item.Creator,
$"StockTaking Id={item.Id}: Creator should be 6, got {item.Creator}");
Assert.IsNotNull(item.StockTakingItems,
$"StockTaking Id={item.Id}: StockTakingItems should not be null when loadRelations=true");
}
}

View File

@ -1,172 +0,0 @@
using AyCode.Core.Enums;
using AyCode.Core.Extensions;
using AyCode.Core.Helpers;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs;
namespace AyCode.Services.Server.Tests.SignalRs;
#region DataSource Implementations
public class TestOrderItemListDataSource : AcSignalRDataSource<TestOrderItem, int, List<TestOrderItem>>
{
public TestOrderItemListDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags crudTags)
: base(signalRClient, crudTags) { }
}
public class TestOrderItemObservableDataSource : AcSignalRDataSource<TestOrderItem, int, AcObservableCollection<TestOrderItem>>
{
public TestOrderItemObservableDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags crudTags)
: base(signalRClient, crudTags) { }
}
#endregion
#region Abstract Test Base
/// <summary>
/// Base class for SignalR DataSource tests.
/// Derived classes specify the serializer type and collection type.
/// </summary>
/// <typeparam name="TDataSource">The concrete DataSource type</typeparam>
/// <typeparam name="TIList">The inner list type (List or AcObservableCollection)</typeparam>
public abstract class SignalRDataSourceTestBase<TDataSource, TIList>
where TDataSource : AcSignalRDataSource<TestOrderItem, int, TIList>
where TIList : class, IList<TestOrderItem>
{
protected abstract AcSerializerOptions SerializerOption { get; }
protected abstract TDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags);
protected TestLogger _logger = null!;
protected TestableSignalRHub2 _hub = null!;
protected TestableSignalRClient2 _client = null!;
protected TestSignalRService2 _service = null!;
protected SignalRCrudTags _crudTags = null!;
[TestInitialize]
public void Setup()
{
_logger = new TestLogger();
_hub = new TestableSignalRHub2();
_service = new TestSignalRService2();
_client = new TestableSignalRClient2(_hub, _logger);
_hub.SetSerializerType(SerializerOption);
_hub.RegisterService(_service, _client);
_crudTags = new SignalRCrudTags(
TestSignalRTags.DataSourceGetAll,
TestSignalRTags.DataSourceGetItem,
TestSignalRTags.DataSourceAdd,
TestSignalRTags.DataSourceUpdate,
TestSignalRTags.DataSourceRemove
);
}
#region Load Tests
[TestMethod]
public async Task LoadDataSource_ReturnsAllItems()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
Assert.AreEqual(3, dataSource.Count);
Assert.AreEqual("Product A", dataSource[0].ProductName);
}
[TestMethod]
public async Task LoadItem_ReturnsSingleItem()
{
var dataSource = CreateDataSource(_client, _crudTags);
var result = await dataSource.LoadItem(2);
Assert.IsNotNull(result);
Assert.AreEqual(2, result.Id);
Assert.AreEqual("Product B", result.ProductName);
}
#endregion
#region Add Tests
[TestMethod]
public async Task Add_WithAutoSave_AddsItem()
{
var dataSource = CreateDataSource(_client, _crudTags);
var newItem = new TestOrderItem { Id = 100, ProductName = "New Product", Quantity = 5, UnitPrice = 50m };
var result = await dataSource.Add(newItem, autoSave: true);
Assert.AreEqual(1, dataSource.Count);
Assert.AreEqual("New Product", result.ProductName);
}
[TestMethod]
public void Add_WithoutAutoSave_AddsToTrackingOnly()
{
var dataSource = CreateDataSource(_client, _crudTags);
var newItem = new TestOrderItem { Id = 100, ProductName = "New Product" };
dataSource.Add(newItem);
Assert.AreEqual(1, dataSource.Count);
Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
Assert.AreEqual(TrackingState.Add, dataSource.GetTrackingItems()[0].TrackingState);
}
#endregion
#region SaveChanges Tests
[TestMethod]
public async Task SaveChanges_SavesTrackedItems()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 101, ProductName = "Item 1" });
dataSource.Add(new TestOrderItem { Id = 102, ProductName = "Item 2" });
var unsaved = await dataSource.SaveChanges();
Assert.AreEqual(0, unsaved.Count);
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
[TestMethod]
public async Task SaveChangesAsync_ClearsTracking()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 103, ProductName = "Item 3" });
await dataSource.SaveChangesAsync();
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
#endregion
}
#endregion
#region DataSources
[TestClass]
public class SignalRDataSourceTests_List : SignalRDataSourceTestBase<TestOrderItemListDataSource, List<TestOrderItem>>
{
protected override AcSerializerOptions SerializerOption => AcBinarySerializerOptions.Default;
protected override TestOrderItemListDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags)
=> new(client, crudTags);
}
[TestClass]
public class SignalRDataSourceTests_Observable : SignalRDataSourceTestBase<TestOrderItemObservableDataSource, AcObservableCollection<TestOrderItem>>
{
protected override AcSerializerOptions SerializerOption => AcBinarySerializerOptions.Default;
protected override TestOrderItemObservableDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags)
=> new(client, crudTags);
}
#endregion

View File

@ -0,0 +1,981 @@
using System.Collections;
using AyCode.Core.Enums;
using AyCode.Core.Extensions;
using AyCode.Core.Helpers;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs;
namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
#region Abstract Test Base
/// <summary>
/// Base class for SignalR DataSource tests with full round-trip coverage.
/// Tests the complete path: DataSource -> SignalRClient -> SignalRHub -> Service -> Response -> SignalRClient -> DataSource
/// Derived classes specify the serializer type and collection type.
/// </summary>
/// <typeparam name="TDataSource">The concrete DataSource type</typeparam>
/// <typeparam name="TIList">The inner list type (List or AcObservableCollection)</typeparam>
public abstract class SignalRDataSourceTestBase<TDataSource, TIList>
where TDataSource : AcSignalRDataSource<TestOrderItem, int, TIList>
where TIList : class, IList<TestOrderItem>
{
protected abstract AcSerializerOptions SerializerOption { get; }
protected abstract TDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags);
protected TestLogger _logger = null!;
protected TestableSignalRHub2 _hub = null!;
protected TestableSignalRClient2 _client = null!;
protected TestSignalRService2 _service = null!;
protected SignalRCrudTags _crudTags = null!;
[TestInitialize]
public void Setup()
{
_logger = new TestLogger();
_hub = new TestableSignalRHub2();
_service = new TestSignalRService2();
_client = new TestableSignalRClient2(_hub, _logger);
_hub.SetSerializerType(SerializerOption);
_hub.RegisterService(_service, _client);
_crudTags = new SignalRCrudTags(
TestSignalRTags.DataSourceGetAll,
TestSignalRTags.DataSourceGetItem,
TestSignalRTags.DataSourceAdd,
TestSignalRTags.DataSourceUpdate,
TestSignalRTags.DataSourceRemove
);
}
#region LoadDataSource Tests
[TestMethod]
public virtual async Task LoadDataSource_ReturnsAllItems()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
Assert.AreEqual(3, dataSource.Count);
Assert.AreEqual("Product A", dataSource[0].ProductName);
Assert.AreEqual("Product B", dataSource[1].ProductName);
Assert.AreEqual("Product C", dataSource[2].ProductName);
}
[TestMethod]
public virtual async Task LoadDataSource_ClearsChangeTracking_ByDefault()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 999, ProductName = "Tracked" });
Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
await dataSource.LoadDataSource();
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
[TestMethod]
public virtual async Task LoadDataSource_PreservesChangeTracking_WhenFalse()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
dataSource.Add(new TestOrderItem { Id = 999, ProductName = "Tracked" });
await dataSource.LoadDataSource(clearChangeTracking: false);
Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
}
[TestMethod]
public virtual async Task LoadDataSource_InvokesOnDataSourceLoaded()
{
var dataSource = CreateDataSource(_client, _crudTags);
var callbackInvoked = false;
dataSource.OnDataSourceLoaded = () => { callbackInvoked = true; return Task.CompletedTask; };
await dataSource.LoadDataSource();
Assert.IsTrue(callbackInvoked);
}
[TestMethod]
public virtual async Task LoadDataSource_MultipleCalls_RefreshesData()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var firstCount = dataSource.Count;
await dataSource.LoadDataSource();
var secondCount = dataSource.Count;
Assert.AreEqual(firstCount, secondCount);
Assert.AreEqual(3, secondCount);
}
#endregion
#region LoadDataSourceAsync Tests
[TestMethod]
public virtual async Task LoadDataSourceAsync_LoadsDataViaCallback()
{
var dataSource = CreateDataSource(_client, _crudTags);
var loadCompleted = false;
dataSource.OnDataSourceLoaded = () =>
{
loadCompleted = true;
return Task.CompletedTask;
};
await dataSource.LoadDataSourceAsync();
Assert.IsTrue(TaskHelper.WaitTo(() => loadCompleted, 5000));
Assert.AreEqual(3, dataSource.Count);
}
#endregion
#region LoadItem Tests
[TestMethod]
public virtual async Task LoadItem_ReturnsSingleItem()
{
var dataSource = CreateDataSource(_client, _crudTags);
var result = await dataSource.LoadItem(2);
Assert.IsNotNull(result);
Assert.AreEqual(2, result.Id);
Assert.AreEqual("Product B", result.ProductName);
}
[TestMethod]
public virtual async Task LoadItem_AddsToDataSource_WhenNotExists()
{
var dataSource = CreateDataSource(_client, _crudTags);
Assert.AreEqual(0, dataSource.Count);
await dataSource.LoadItem(1);
Assert.AreEqual(1, dataSource.Count);
Assert.AreEqual("Product A", dataSource[0].ProductName);
}
[TestMethod]
public virtual async Task LoadItem_UpdatesExisting_WhenAlreadyLoaded()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var originalItem = dataSource[0];
var reloaded = await dataSource.LoadItem(originalItem.Id);
Assert.AreEqual(3, dataSource.Count);
Assert.IsNotNull(reloaded);
}
[TestMethod]
public virtual async Task LoadItem_InvokesOnDataSourceItemChanged()
{
var dataSource = CreateDataSource(_client, _crudTags);
ItemChangedEventArgs<TestOrderItem>? eventArgs = null;
dataSource.OnDataSourceItemChanged = args => { eventArgs = args; return Task.CompletedTask; };
await dataSource.LoadItem(1);
Assert.IsNotNull(eventArgs);
Assert.AreEqual(TrackingState.Get, eventArgs.TrackingState);
Assert.AreEqual(1, eventArgs.Item.Id);
}
[TestMethod]
public virtual async Task LoadItem_ReturnsNull_WhenNotFound()
{
var dataSource = CreateDataSource(_client, _crudTags);
var result = await dataSource.LoadItem(9999);
Assert.IsNull(result);
}
#endregion
#region Add Tests
[TestMethod]
public virtual async Task Add_WithAutoSave_AddsItem()
{
var dataSource = CreateDataSource(_client, _crudTags);
var newItem = new TestOrderItem { Id = 100, ProductName = "New Product", Quantity = 5, UnitPrice = 50m };
var result = await dataSource.Add(newItem, autoSave: true);
Assert.AreEqual(1, dataSource.Count);
Assert.AreEqual("New Product", result.ProductName);
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
[TestMethod]
public virtual void Add_WithoutAutoSave_AddsToTrackingOnly()
{
var dataSource = CreateDataSource(_client, _crudTags);
var newItem = new TestOrderItem { Id = 100, ProductName = "New Product" };
dataSource.Add(newItem);
Assert.AreEqual(1, dataSource.Count);
Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
Assert.AreEqual(TrackingState.Add, dataSource.GetTrackingItems()[0].TrackingState);
}
[TestMethod]
public virtual void Add_DuplicateId_ThrowsException()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 100, ProductName = "First" });
Assert.ThrowsExactly<ArgumentException>(() =>
{
dataSource.Add(new TestOrderItem { Id = 100, ProductName = "Duplicate" });
});
}
[TestMethod]
public virtual void Add_DefaultId_ThrowsException()
{
var dataSource = CreateDataSource(_client, _crudTags);
Assert.ThrowsExactly<ArgumentNullException>(() =>
{
dataSource.Add(new TestOrderItem { Id = 0, ProductName = "Invalid" });
});
}
[TestMethod]
public virtual void AddRange_AddsMultipleItems()
{
var dataSource = CreateDataSource(_client, _crudTags);
var items = new[]
{
new TestOrderItem { Id = 101, ProductName = "Item 1" },
new TestOrderItem { Id = 102, ProductName = "Item 2" },
new TestOrderItem { Id = 103, ProductName = "Item 3" }
};
dataSource.AddRange(items);
Assert.AreEqual(3, dataSource.Count);
}
#endregion
#region AddOrUpdate Tests
[TestMethod]
public virtual async Task AddOrUpdate_AddsNew_WhenNotExists()
{
var dataSource = CreateDataSource(_client, _crudTags);
var newItem = new TestOrderItem { Id = 200, ProductName = "Brand New" };
var result = await dataSource.AddOrUpdate(newItem, autoSave: true);
Assert.AreEqual(1, dataSource.Count);
Assert.AreEqual("Brand New", result.ProductName);
}
[TestMethod]
public virtual async Task AddOrUpdate_UpdatesExisting_WhenExists()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var existingId = dataSource[0].Id;
var updatedItem = new TestOrderItem { Id = existingId, ProductName = "Updated Name", Quantity = 999 };
_ = await dataSource.AddOrUpdate(updatedItem, autoSave: true);
Assert.AreEqual(3, dataSource.Count);
Assert.AreEqual("Updated Name", dataSource[0].ProductName);
}
#endregion
#region Insert Tests
[TestMethod]
public virtual void Insert_AtIndex_InsertsCorrectly()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "First" });
dataSource.Add(new TestOrderItem { Id = 3, ProductName = "Third" });
dataSource.Insert(1, new TestOrderItem { Id = 2, ProductName = "Second" });
Assert.AreEqual(3, dataSource.Count);
Assert.AreEqual("Second", dataSource[1].ProductName);
Assert.AreEqual(3, dataSource.GetTrackingItems().Count);
}
[TestMethod]
public virtual async Task Insert_WithAutoSave_SavesImmediately()
{
var dataSource = CreateDataSource(_client, _crudTags);
var newItem = new TestOrderItem { Id = 500, ProductName = "Inserted" };
_ = await dataSource.Insert(0, newItem, autoSave: true);
Assert.AreEqual(1, dataSource.Count);
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
#endregion
#region Update Tests
[TestMethod]
public virtual async Task Update_ByIndex_UpdatesCorrectly()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var updatedItem = new TestOrderItem
{
Id = dataSource[0].Id,
ProductName = "Updated Product",
Quantity = 100
};
_ = await dataSource.Update(0, updatedItem, autoSave: true);
Assert.AreEqual("Updated Product", dataSource[0].ProductName);
Assert.AreEqual(100, dataSource[0].Quantity);
}
[TestMethod]
public virtual async Task Update_ByItem_UpdatesCorrectly()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var updatedItem = new TestOrderItem
{
Id = dataSource[1].Id,
ProductName = "Updated B",
Quantity = 50
};
_ = await dataSource.Update(updatedItem, autoSave: true);
Assert.AreEqual("Updated B", dataSource[1].ProductName);
}
[TestMethod]
public virtual void Indexer_Set_TracksUpdate()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Original" });
dataSource[0] = new TestOrderItem { Id = 1, ProductName = "Modified" };
Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
Assert.AreEqual(TrackingState.Add, dataSource.GetTrackingItems()[0].TrackingState);
}
#endregion
#region Remove Tests
[TestMethod]
public virtual async Task Remove_ById_RemovesItem()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var idToRemove = dataSource[0].Id;
var result = await dataSource.Remove(idToRemove, autoSave: true);
Assert.IsTrue(result);
Assert.AreEqual(2, dataSource.Count);
}
[TestMethod]
public virtual async Task Remove_ByItem_RemovesItem()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var itemToRemove = dataSource[1];
var result = await dataSource.Remove(itemToRemove, autoSave: true);
Assert.IsTrue(result);
Assert.AreEqual(2, dataSource.Count);
}
[TestMethod]
public virtual void Remove_WithoutAutoSave_TracksRemoval()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "ToRemove" });
dataSource.GetTrackingItems().Clear();
dataSource.Remove(dataSource[0]);
Assert.AreEqual(0, dataSource.Count);
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
Assert.AreEqual(TrackingState.Remove, dataSource.GetTrackingItems()[0].TrackingState);
}
[TestMethod]
public virtual void RemoveAt_RemovesCorrectItem()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "First" });
dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Second" });
dataSource.Add(new TestOrderItem { Id = 3, ProductName = "Third" });
dataSource.GetTrackingItems().Clear();
dataSource.RemoveAt(1);
Assert.AreEqual(2, dataSource.Count);
Assert.AreEqual("First", dataSource[0].ProductName);
Assert.AreEqual("Third", dataSource[1].ProductName);
}
[TestMethod]
public virtual async Task RemoveAt_WithAutoSave_SavesImmediately()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
await dataSource.RemoveAt(0, autoSave: true);
Assert.AreEqual(2, dataSource.Count);
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
[TestMethod]
public virtual void TryRemove_ReturnsTrue_WhenExists()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Test" });
var result = dataSource.TryRemove(1, out var removedItem);
Assert.IsTrue(result);
Assert.IsNotNull(removedItem);
Assert.AreEqual("Test", removedItem.ProductName);
}
[TestMethod]
public virtual void TryRemove_ReturnsFalse_WhenNotExists()
{
var dataSource = CreateDataSource(_client, _crudTags);
var result = dataSource.TryRemove(9999, out var removedItem);
Assert.IsFalse(result);
Assert.IsNull(removedItem);
}
#endregion
#region SaveChanges Tests
[TestMethod]
public virtual async Task SaveChanges_SavesTrackedItems()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 101, ProductName = "Item 1" });
dataSource.Add(new TestOrderItem { Id = 102, ProductName = "Item 2" });
var unsaved = await dataSource.SaveChanges();
Assert.AreEqual(0, unsaved.Count);
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
[TestMethod]
public virtual async Task SaveChangesAsync_ClearsTracking()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 103, ProductName = "Item 3" });
await dataSource.SaveChangesAsync();
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
[TestMethod]
public virtual async Task SaveItem_ById_SavesSpecificItem()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 201, ProductName = "Specific" });
var result = await dataSource.SaveItem(201);
Assert.AreEqual("Specific", result.ProductName);
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
[TestMethod]
public virtual async Task SaveItem_WithTrackingState_SavesCorrectly()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var item = dataSource[0];
var result = await dataSource.SaveItem(item, TrackingState.Update);
Assert.IsNotNull(result);
}
#endregion
#region Tracking Tests
[TestMethod]
public virtual void SetTrackingStateToUpdate_MarksItemForUpdate()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Test" });
//await dataSource.SaveChanges(); // Elõbb mentsük el, hogy ne Add legyen a tracking state
dataSource.SetTrackingStateToUpdate(dataSource[0]);
Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
Assert.AreEqual(TrackingState.Update, dataSource.GetTrackingItems()[0].TrackingState);
}
[TestMethod]
public virtual void SetTrackingStateToUpdate_DoesNotChangeAddState()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "New Item" });
dataSource.SetTrackingStateToUpdate(dataSource[0]);
Assert.AreEqual(TrackingState.Add, dataSource.GetTrackingItems()[0].TrackingState);
}
[TestMethod]
public virtual void TryGetTrackingItem_ReturnsTrue_WhenTracked()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Tracked" });
var result = dataSource.TryGetTrackingItem(1, out var trackingItem);
Assert.IsTrue(result);
Assert.IsNotNull(trackingItem);
Assert.AreEqual(TrackingState.Add, trackingItem.TrackingState);
}
[TestMethod]
public virtual void TryGetTrackingItem_ReturnsFalse_WhenNotTracked()
{
var dataSource = CreateDataSource(_client, _crudTags);
var result = dataSource.TryGetTrackingItem(9999, out var trackingItem);
Assert.IsFalse(result);
Assert.IsNull(trackingItem);
}
#endregion
#region Rollback Tests
[TestMethod]
public virtual void TryRollbackItem_RevertsAddedItem()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Added" });
var result = dataSource.TryRollbackItem(1, out var originalValue);
Assert.IsTrue(result);
Assert.AreEqual(0, dataSource.Count);
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
[TestMethod]
public virtual async Task TryRollbackItem_RevertsUpdatedItem()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var originalName = dataSource[0].ProductName;
dataSource[0] = new TestOrderItem { Id = dataSource[0].Id, ProductName = "Changed" };
var result = dataSource.TryRollbackItem(dataSource[0].Id, out var originalValue);
Assert.IsTrue(result);
Assert.IsNotNull(originalValue);
}
[TestMethod]
public virtual void Rollback_RevertsAllChanges()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1" });
dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Item2" });
dataSource.Rollback();
Assert.AreEqual(0, dataSource.Count);
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
#endregion
#region Collection Operations Tests
[TestMethod]
public virtual async Task Count_ReturnsCorrectValue()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
Assert.AreEqual(3, dataSource.Count);
}
[TestMethod]
public virtual void Clear_RemovesAllItems()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1" });
dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Item2" });
dataSource.Clear();
Assert.AreEqual(0, dataSource.Count);
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
[TestMethod]
public virtual void Clear_WithoutClearingTracking_PreservesTracking()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1" });
dataSource.Clear(clearChangeTracking: false);
Assert.AreEqual(0, dataSource.Count);
Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
}
[TestMethod]
public virtual async Task Contains_ReturnsTrue_WhenItemExists()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
Assert.IsTrue(dataSource.Contains(dataSource[0]));
}
[TestMethod]
public virtual void Contains_ReturnsFalse_WhenItemNotExists()
{
var dataSource = CreateDataSource(_client, _crudTags);
Assert.IsFalse(dataSource.Contains(new TestOrderItem { Id = 9999 }));
}
[TestMethod]
public virtual async Task IndexOf_ReturnsCorrectIndex()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
Assert.AreEqual(0, dataSource.IndexOf(dataSource[0]));
Assert.AreEqual(1, dataSource.IndexOf(dataSource[1]));
Assert.AreEqual(2, dataSource.IndexOf(dataSource[2]));
}
[TestMethod]
public virtual void IndexOf_ById_ReturnsCorrectIndex()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 5, ProductName = "Item5" });
Assert.AreEqual(0, dataSource.IndexOf(5));
}
[TestMethod]
public virtual void TryGetIndex_ReturnsTrue_WhenExists()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 10, ProductName = "Test" });
var result = dataSource.TryGetIndex(10, out var index);
Assert.IsTrue(result);
Assert.AreEqual(0, index);
}
[TestMethod]
public virtual async Task TryGetValue_ReturnsItem_WhenExists()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var result = dataSource.TryGetValue(1, out var item);
Assert.IsTrue(result);
Assert.IsNotNull(item);
Assert.AreEqual("Product A", item.ProductName);
}
[TestMethod]
public virtual void TryGetValue_ReturnsFalse_WhenNotExists()
{
var dataSource = CreateDataSource(_client, _crudTags);
var result = dataSource.TryGetValue(9999, out var item);
Assert.IsFalse(result);
Assert.IsNull(item);
}
[TestMethod]
public virtual async Task CopyTo_CopiesAllItems()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var array = new TestOrderItem[3];
dataSource.CopyTo(array);
Assert.AreEqual("Product A", array[0].ProductName);
Assert.AreEqual("Product B", array[1].ProductName);
Assert.AreEqual("Product C", array[2].ProductName);
}
[TestMethod]
public virtual async Task GetEnumerator_EnumeratesAllItems()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var count = dataSource.Count();
Assert.AreEqual(3, count);
}
[TestMethod]
public virtual async Task AsReadOnly_ReturnsReadOnlyCollection()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var readOnly = dataSource.AsReadOnly();
Assert.AreEqual(3, readOnly.Count);
Assert.IsInstanceOfType(readOnly, typeof(System.Collections.ObjectModel.ReadOnlyCollection<TestOrderItem>));
}
#endregion
#region Working Reference List Tests
[TestMethod]
public virtual async Task SetWorkingReferenceList_SetsNewInnerList()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var externalList = Activator.CreateInstance<TIList>();
dataSource.SetWorkingReferenceList(externalList);
Assert.IsTrue(dataSource.HasWorkingReferenceList);
Assert.AreEqual(3, dataSource.Count);
}
[TestMethod]
public virtual async Task GetReferenceInnerList_ReturnsInnerList()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
var innerList = dataSource.GetReferenceInnerList();
Assert.IsNotNull(innerList);
Assert.AreEqual(3, innerList.Count);
}
#endregion
#region Sync State Tests
[TestMethod]
public virtual void IsSyncing_IsFalse_Initially()
{
var dataSource = CreateDataSource(_client, _crudTags);
Assert.IsFalse(dataSource.IsSyncing);
}
[TestMethod]
public virtual async Task OnSyncingStateChanged_Fires_DuringLoad()
{
var dataSource = CreateDataSource(_client, _crudTags);
var syncStarted = false;
var syncEnded = false;
dataSource.OnSyncingStateChanged += isSyncing =>
{
if (isSyncing) syncStarted = true;
else syncEnded = true;
};
await dataSource.LoadDataSource();
Assert.IsTrue(syncStarted);
Assert.IsTrue(syncEnded);
}
#endregion
#region Context and Filter Tests
[TestMethod]
public virtual void ContextIds_CanBeSetAndRetrieved()
{
var dataSource = CreateDataSource(_client, _crudTags);
var contextIds = new object[] { 1, "test", Guid.NewGuid() };
dataSource.ContextIds = contextIds;
Assert.AreEqual(3, dataSource.ContextIds?.Length);
}
[TestMethod]
public virtual void FilterText_CanBeSetAndRetrieved()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.FilterText = "search term";
Assert.AreEqual("search term", dataSource.FilterText);
}
#endregion
#region IList Interface Tests
[TestMethod]
public virtual void IList_Add_ReturnsCorrectIndex()
{
var dataSource = CreateDataSource(_client, _crudTags);
var item = new TestOrderItem { Id = 1, ProductName = "Test" };
var index = ((IList)dataSource).Add(item);
Assert.AreEqual(0, index);
}
[TestMethod]
public virtual void IList_Contains_WorksCorrectly()
{
var dataSource = CreateDataSource(_client, _crudTags);
var item = new TestOrderItem { Id = 1, ProductName = "Test" };
dataSource.Add(item);
Assert.IsTrue(((IList)dataSource).Contains(item));
}
[TestMethod]
public virtual void IList_IndexOf_WorksCorrectly()
{
var dataSource = CreateDataSource(_client, _crudTags);
var item = new TestOrderItem { Id = 1, ProductName = "Test" };
dataSource.Add(item);
Assert.AreEqual(0, ((IList)dataSource).IndexOf(item));
}
[TestMethod]
public virtual void IList_Insert_WorksCorrectly()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "First" });
var newItem = new TestOrderItem { Id = 2, ProductName = "Inserted" };
((IList)dataSource).Insert(0, newItem);
Assert.AreEqual("Inserted", dataSource[0].ProductName);
}
[TestMethod]
public virtual void IList_Remove_WorksCorrectly()
{
var dataSource = CreateDataSource(_client, _crudTags);
var item = new TestOrderItem { Id = 1, ProductName = "Test" };
dataSource.Add(item);
((IList)dataSource).Remove(item);
Assert.AreEqual(0, dataSource.Count);
}
[TestMethod]
public virtual void IList_Indexer_GetSet_WorksCorrectly()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Original" });
((IList)dataSource)[0] = new TestOrderItem { Id = 1, ProductName = "Modified" };
Assert.AreEqual("Modified", dataSource[0].ProductName);
}
[TestMethod]
public virtual void ICollection_CopyTo_WorksCorrectly()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1" });
dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Item2" });
var array = new TestOrderItem[2];
((ICollection)dataSource).CopyTo(array, 0);
Assert.AreEqual("Item1", array[0].ProductName);
Assert.AreEqual("Item2", array[1].ProductName);
}
[TestMethod]
public virtual void IsSynchronized_ReturnsTrue()
{
var dataSource = CreateDataSource(_client, _crudTags);
Assert.IsTrue(dataSource.IsSynchronized);
}
[TestMethod]
public virtual void SyncRoot_IsNotNull()
{
var dataSource = CreateDataSource(_client, _crudTags);
Assert.IsNotNull(dataSource.SyncRoot);
}
[TestMethod]
public virtual void IsFixedSize_ReturnsFalse()
{
var dataSource = CreateDataSource(_client, _crudTags);
Assert.IsFalse(dataSource.IsFixedSize);
}
[TestMethod]
public virtual void IsReadOnly_ReturnsFalse()
{
var dataSource = CreateDataSource(_client, _crudTags);
Assert.IsFalse(((IList)dataSource).IsReadOnly);
}
#endregion
#region Edge Cases
[TestMethod]
public virtual async Task Indexer_OutOfRange_ThrowsException()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => _ = dataSource[999]);
}
[TestMethod]
public virtual void Add_ThenRemove_ClearsTracking()
{
var dataSource = CreateDataSource(_client, _crudTags);
var item = new TestOrderItem { Id = 1, ProductName = "Temporary" };
dataSource.Add(item);
Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
dataSource.Remove(item);
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
[TestMethod]
public virtual async Task ComplexWorkflow_AddUpdateRemoveSave()
{
var dataSource = CreateDataSource(_client, _crudTags);
// Add items
dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1", Quantity = 10 });
dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Item2", Quantity = 20 });
Assert.AreEqual(2, dataSource.GetTrackingItems().Count);
// Save
await dataSource.SaveChanges();
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
// Update
dataSource[0] = new TestOrderItem { Id = 1, ProductName = "Updated1", Quantity = 100 };
Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
Assert.AreEqual(TrackingState.Update, dataSource.GetTrackingItems()[0].TrackingState);
// Remove
dataSource.Remove(dataSource[1]);
Assert.AreEqual(2, dataSource.GetTrackingItems().Count);
// Save all changes
await dataSource.SaveChanges();
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
Assert.AreEqual(1, dataSource.Count);
}
#endregion
}
#endregion

View File

@ -0,0 +1,163 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.SignalRs;
namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
[TestClass]
public class SignalRDataSourceTests_List_Binary : SignalRDataSourceTestBase<TestOrderItemListDataSource, List<TestOrderItem>>
{
protected override AcSerializerOptions SerializerOption => new AcBinarySerializerOptions();
protected override TestOrderItemListDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags)
=> new(client, crudTags);
[TestMethod]
public override async Task LoadDataSource_ReturnsAllItems() => await base.LoadDataSource_ReturnsAllItems();
[TestMethod]
public override async Task LoadDataSource_ClearsChangeTracking_ByDefault() => await base.LoadDataSource_ClearsChangeTracking_ByDefault();
[TestMethod]
public override async Task LoadDataSource_PreservesChangeTracking_WhenFalse() => await base.LoadDataSource_PreservesChangeTracking_WhenFalse();
[TestMethod]
public override async Task LoadDataSource_InvokesOnDataSourceLoaded() => await base.LoadDataSource_InvokesOnDataSourceLoaded();
[TestMethod]
public override async Task LoadDataSource_MultipleCalls_RefreshesData() => await base.LoadDataSource_MultipleCalls_RefreshesData();
[TestMethod]
public override async Task LoadDataSourceAsync_LoadsDataViaCallback() => await base.LoadDataSourceAsync_LoadsDataViaCallback();
[TestMethod]
public override async Task LoadItem_ReturnsSingleItem() => await base.LoadItem_ReturnsSingleItem();
[TestMethod]
public override async Task LoadItem_AddsToDataSource_WhenNotExists() => await base.LoadItem_AddsToDataSource_WhenNotExists();
[TestMethod]
public override async Task LoadItem_UpdatesExisting_WhenAlreadyLoaded() => await base.LoadItem_UpdatesExisting_WhenAlreadyLoaded();
[TestMethod]
public override async Task LoadItem_InvokesOnDataSourceItemChanged() => await base.LoadItem_InvokesOnDataSourceItemChanged();
[TestMethod]
public override async Task LoadItem_ReturnsNull_WhenNotFound() => await base.LoadItem_ReturnsNull_WhenNotFound();
[TestMethod]
public override async Task Add_WithAutoSave_AddsItem() => await base.Add_WithAutoSave_AddsItem();
[TestMethod]
public override void Add_WithoutAutoSave_AddsToTrackingOnly() => base.Add_WithoutAutoSave_AddsToTrackingOnly();
[TestMethod]
public override void Add_DuplicateId_ThrowsException() => base.Add_DuplicateId_ThrowsException();
[TestMethod]
public override void Add_DefaultId_ThrowsException() => base.Add_DefaultId_ThrowsException();
[TestMethod]
public override void AddRange_AddsMultipleItems() => base.AddRange_AddsMultipleItems();
[TestMethod]
public override async Task AddOrUpdate_AddsNew_WhenNotExists() => await base.AddOrUpdate_AddsNew_WhenNotExists();
[TestMethod]
public override async Task AddOrUpdate_UpdatesExisting_WhenExists() => await base.AddOrUpdate_UpdatesExisting_WhenExists();
[TestMethod]
public override void Insert_AtIndex_InsertsCorrectly() => base.Insert_AtIndex_InsertsCorrectly();
[TestMethod]
public override async Task Insert_WithAutoSave_SavesImmediately() => await base.Insert_WithAutoSave_SavesImmediately();
[TestMethod]
public override async Task Update_ByIndex_UpdatesCorrectly() => await base.Update_ByIndex_UpdatesCorrectly();
[TestMethod]
public override async Task Update_ByItem_UpdatesCorrectly() => await base.Update_ByItem_UpdatesCorrectly();
[TestMethod]
public override void Indexer_Set_TracksUpdate() => base.Indexer_Set_TracksUpdate();
[TestMethod]
public override async Task Remove_ById_RemovesItem() => await base.Remove_ById_RemovesItem();
[TestMethod]
public override async Task Remove_ByItem_RemovesItem() => await base.Remove_ByItem_RemovesItem();
[TestMethod]
public override void Remove_WithoutAutoSave_TracksRemoval() => base.Remove_WithoutAutoSave_TracksRemoval();
[TestMethod]
public override void RemoveAt_RemovesCorrectItem() => base.RemoveAt_RemovesCorrectItem();
[TestMethod]
public override async Task RemoveAt_WithAutoSave_SavesImmediately() => await base.RemoveAt_WithAutoSave_SavesImmediately();
[TestMethod]
public override void TryRemove_ReturnsTrue_WhenExists() => base.TryRemove_ReturnsTrue_WhenExists();
[TestMethod]
public override void TryRemove_ReturnsFalse_WhenNotExists() => base.TryRemove_ReturnsFalse_WhenNotExists();
[TestMethod]
public override async Task SaveChanges_SavesTrackedItems() => await base.SaveChanges_SavesTrackedItems();
[TestMethod]
public override async Task SaveChangesAsync_ClearsTracking() => await base.SaveChangesAsync_ClearsTracking();
[TestMethod]
public override async Task SaveItem_ById_SavesSpecificItem() => await base.SaveItem_ById_SavesSpecificItem();
[TestMethod]
public override async Task SaveItem_WithTrackingState_SavesCorrectly() => await base.SaveItem_WithTrackingState_SavesCorrectly();
[TestMethod]
public override void SetTrackingStateToUpdate_MarksItemForUpdate() => base.SetTrackingStateToUpdate_MarksItemForUpdate();
[TestMethod]
public override void SetTrackingStateToUpdate_DoesNotChangeAddState() => base.SetTrackingStateToUpdate_DoesNotChangeAddState();
[TestMethod]
public override void TryGetTrackingItem_ReturnsTrue_WhenTracked() => base.TryGetTrackingItem_ReturnsTrue_WhenTracked();
[TestMethod]
public override void TryGetTrackingItem_ReturnsFalse_WhenNotTracked() => base.TryGetTrackingItem_ReturnsFalse_WhenNotTracked();
[TestMethod]
public override void TryRollbackItem_RevertsAddedItem() => base.TryRollbackItem_RevertsAddedItem();
[TestMethod]
public override async Task TryRollbackItem_RevertsUpdatedItem() => await base.TryRollbackItem_RevertsUpdatedItem();
[TestMethod]
public override void Rollback_RevertsAllChanges() => base.Rollback_RevertsAllChanges();
[TestMethod]
public override async Task Count_ReturnsCorrectValue() => await base.Count_ReturnsCorrectValue();
[TestMethod]
public override void Clear_RemovesAllItems() => base.Clear_RemovesAllItems();
[TestMethod]
public override void Clear_WithoutClearingTracking_PreservesTracking() => base.Clear_WithoutClearingTracking_PreservesTracking();
[TestMethod]
public override async Task Contains_ReturnsTrue_WhenItemExists() => await base.Contains_ReturnsTrue_WhenItemExists();
[TestMethod]
public override void Contains_ReturnsFalse_WhenItemNotExists() => base.Contains_ReturnsFalse_WhenItemNotExists();
[TestMethod]
public override async Task IndexOf_ReturnsCorrectIndex() => await base.IndexOf_ReturnsCorrectIndex();
[TestMethod]
public override void IndexOf_ById_ReturnsCorrectIndex() => base.IndexOf_ById_ReturnsCorrectIndex();
[TestMethod]
public override void TryGetIndex_ReturnsTrue_WhenExists() => base.TryGetIndex_ReturnsTrue_WhenExists();
[TestMethod]
public override async Task TryGetValue_ReturnsItem_WhenExists() => await base.TryGetValue_ReturnsItem_WhenExists();
[TestMethod]
public override void TryGetValue_ReturnsFalse_WhenNotExists() => base.TryGetValue_ReturnsFalse_WhenNotExists();
[TestMethod]
public override async Task CopyTo_CopiesAllItems() => await base.CopyTo_CopiesAllItems();
[TestMethod]
public override async Task GetEnumerator_EnumeratesAllItems() => await base.GetEnumerator_EnumeratesAllItems();
[TestMethod]
public override async Task AsReadOnly_ReturnsReadOnlyCollection() => await base.AsReadOnly_ReturnsReadOnlyCollection();
[TestMethod]
public override async Task SetWorkingReferenceList_SetsNewInnerList() => await base.SetWorkingReferenceList_SetsNewInnerList();
[TestMethod]
public override async Task GetReferenceInnerList_ReturnsInnerList() => await base.GetReferenceInnerList_ReturnsInnerList();
[TestMethod]
public override void IsSyncing_IsFalse_Initially() => base.IsSyncing_IsFalse_Initially();
[TestMethod]
public override async Task OnSyncingStateChanged_Fires_DuringLoad() => await base.OnSyncingStateChanged_Fires_DuringLoad();
[TestMethod]
public override void ContextIds_CanBeSetAndRetrieved() => base.ContextIds_CanBeSetAndRetrieved();
[TestMethod]
public override void FilterText_CanBeSetAndRetrieved() => base.FilterText_CanBeSetAndRetrieved();
[TestMethod]
public override void IList_Add_ReturnsCorrectIndex() => base.IList_Add_ReturnsCorrectIndex();
[TestMethod]
public override void IList_Contains_WorksCorrectly() => base.IList_Contains_WorksCorrectly();
[TestMethod]
public override void IList_IndexOf_WorksCorrectly() => base.IList_IndexOf_WorksCorrectly();
[TestMethod]
public override void IList_Insert_WorksCorrectly() => base.IList_Insert_WorksCorrectly();
[TestMethod]
public override void IList_Remove_WorksCorrectly() => base.IList_Remove_WorksCorrectly();
[TestMethod]
public override void IList_Indexer_GetSet_WorksCorrectly() => base.IList_Indexer_GetSet_WorksCorrectly();
[TestMethod]
public override void ICollection_CopyTo_WorksCorrectly() => base.ICollection_CopyTo_WorksCorrectly();
[TestMethod]
public override void IsSynchronized_ReturnsTrue() => base.IsSynchronized_ReturnsTrue();
[TestMethod]
public override void SyncRoot_IsNotNull() => base.SyncRoot_IsNotNull();
[TestMethod]
public override void IsFixedSize_ReturnsFalse() => base.IsFixedSize_ReturnsFalse();
[TestMethod]
public override void IsReadOnly_ReturnsFalse() => base.IsReadOnly_ReturnsFalse();
[TestMethod]
public override async Task Indexer_OutOfRange_ThrowsException() => await base.Indexer_OutOfRange_ThrowsException();
[TestMethod]
public override void Add_ThenRemove_ClearsTracking() => base.Add_ThenRemove_ClearsTracking();
[TestMethod]
public override async Task ComplexWorkflow_AddUpdateRemoveSave() => await base.ComplexWorkflow_AddUpdateRemoveSave();
}

View File

@ -0,0 +1,163 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.SignalRs;
namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
[TestClass]
public class SignalRDataSourceTests_List_Binary_NoRef : SignalRDataSourceTestBase<TestOrderItemListDataSource, List<TestOrderItem>>
{
protected override AcSerializerOptions SerializerOption => new AcBinarySerializerOptions { UseReferenceHandling = false };
protected override TestOrderItemListDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags)
=> new(client, crudTags);
[TestMethod]
public override async Task LoadDataSource_ReturnsAllItems() => await base.LoadDataSource_ReturnsAllItems();
[TestMethod]
public override async Task LoadDataSource_ClearsChangeTracking_ByDefault() => await base.LoadDataSource_ClearsChangeTracking_ByDefault();
[TestMethod]
public override async Task LoadDataSource_PreservesChangeTracking_WhenFalse() => await base.LoadDataSource_PreservesChangeTracking_WhenFalse();
[TestMethod]
public override async Task LoadDataSource_InvokesOnDataSourceLoaded() => await base.LoadDataSource_InvokesOnDataSourceLoaded();
[TestMethod]
public override async Task LoadDataSource_MultipleCalls_RefreshesData() => await base.LoadDataSource_MultipleCalls_RefreshesData();
[TestMethod]
public override async Task LoadDataSourceAsync_LoadsDataViaCallback() => await base.LoadDataSourceAsync_LoadsDataViaCallback();
[TestMethod]
public override async Task LoadItem_ReturnsSingleItem() => await base.LoadItem_ReturnsSingleItem();
[TestMethod]
public override async Task LoadItem_AddsToDataSource_WhenNotExists() => await base.LoadItem_AddsToDataSource_WhenNotExists();
[TestMethod]
public override async Task LoadItem_UpdatesExisting_WhenAlreadyLoaded() => await base.LoadItem_UpdatesExisting_WhenAlreadyLoaded();
[TestMethod]
public override async Task LoadItem_InvokesOnDataSourceItemChanged() => await base.LoadItem_InvokesOnDataSourceItemChanged();
[TestMethod]
public override async Task LoadItem_ReturnsNull_WhenNotFound() => await base.LoadItem_ReturnsNull_WhenNotFound();
[TestMethod]
public override async Task Add_WithAutoSave_AddsItem() => await base.Add_WithAutoSave_AddsItem();
[TestMethod]
public override void Add_WithoutAutoSave_AddsToTrackingOnly() => base.Add_WithoutAutoSave_AddsToTrackingOnly();
[TestMethod]
public override void Add_DuplicateId_ThrowsException() => base.Add_DuplicateId_ThrowsException();
[TestMethod]
public override void Add_DefaultId_ThrowsException() => base.Add_DefaultId_ThrowsException();
[TestMethod]
public override void AddRange_AddsMultipleItems() => base.AddRange_AddsMultipleItems();
[TestMethod]
public override async Task AddOrUpdate_AddsNew_WhenNotExists() => await base.AddOrUpdate_AddsNew_WhenNotExists();
[TestMethod]
public override async Task AddOrUpdate_UpdatesExisting_WhenExists() => await base.AddOrUpdate_UpdatesExisting_WhenExists();
[TestMethod]
public override void Insert_AtIndex_InsertsCorrectly() => base.Insert_AtIndex_InsertsCorrectly();
[TestMethod]
public override async Task Insert_WithAutoSave_SavesImmediately() => await base.Insert_WithAutoSave_SavesImmediately();
[TestMethod]
public override async Task Update_ByIndex_UpdatesCorrectly() => await base.Update_ByIndex_UpdatesCorrectly();
[TestMethod]
public override async Task Update_ByItem_UpdatesCorrectly() => await base.Update_ByItem_UpdatesCorrectly();
[TestMethod]
public override void Indexer_Set_TracksUpdate() => base.Indexer_Set_TracksUpdate();
[TestMethod]
public override async Task Remove_ById_RemovesItem() => await base.Remove_ById_RemovesItem();
[TestMethod]
public override async Task Remove_ByItem_RemovesItem() => await base.Remove_ByItem_RemovesItem();
[TestMethod]
public override void Remove_WithoutAutoSave_TracksRemoval() => base.Remove_WithoutAutoSave_TracksRemoval();
[TestMethod]
public override void RemoveAt_RemovesCorrectItem() => base.RemoveAt_RemovesCorrectItem();
[TestMethod]
public override async Task RemoveAt_WithAutoSave_SavesImmediately() => await base.RemoveAt_WithAutoSave_SavesImmediately();
[TestMethod]
public override void TryRemove_ReturnsTrue_WhenExists() => base.TryRemove_ReturnsTrue_WhenExists();
[TestMethod]
public override void TryRemove_ReturnsFalse_WhenNotExists() => base.TryRemove_ReturnsFalse_WhenNotExists();
[TestMethod]
public override async Task SaveChanges_SavesTrackedItems() => await base.SaveChanges_SavesTrackedItems();
[TestMethod]
public override async Task SaveChangesAsync_ClearsTracking() => await base.SaveChangesAsync_ClearsTracking();
[TestMethod]
public override async Task SaveItem_ById_SavesSpecificItem() => await base.SaveItem_ById_SavesSpecificItem();
[TestMethod]
public override async Task SaveItem_WithTrackingState_SavesCorrectly() => await base.SaveItem_WithTrackingState_SavesCorrectly();
[TestMethod]
public override void SetTrackingStateToUpdate_MarksItemForUpdate() => base.SetTrackingStateToUpdate_MarksItemForUpdate();
[TestMethod]
public override void SetTrackingStateToUpdate_DoesNotChangeAddState() => base.SetTrackingStateToUpdate_DoesNotChangeAddState();
[TestMethod]
public override void TryGetTrackingItem_ReturnsTrue_WhenTracked() => base.TryGetTrackingItem_ReturnsTrue_WhenTracked();
[TestMethod]
public override void TryGetTrackingItem_ReturnsFalse_WhenNotTracked() => base.TryGetTrackingItem_ReturnsFalse_WhenNotTracked();
[TestMethod]
public override void TryRollbackItem_RevertsAddedItem() => base.TryRollbackItem_RevertsAddedItem();
[TestMethod]
public override async Task TryRollbackItem_RevertsUpdatedItem() => await base.TryRollbackItem_RevertsUpdatedItem();
[TestMethod]
public override void Rollback_RevertsAllChanges() => base.Rollback_RevertsAllChanges();
[TestMethod]
public override async Task Count_ReturnsCorrectValue() => await base.Count_ReturnsCorrectValue();
[TestMethod]
public override void Clear_RemovesAllItems() => base.Clear_RemovesAllItems();
[TestMethod]
public override void Clear_WithoutClearingTracking_PreservesTracking() => base.Clear_WithoutClearingTracking_PreservesTracking();
[TestMethod]
public override async Task Contains_ReturnsTrue_WhenItemExists() => await base.Contains_ReturnsTrue_WhenItemExists();
[TestMethod]
public override void Contains_ReturnsFalse_WhenItemNotExists() => base.Contains_ReturnsFalse_WhenItemNotExists();
[TestMethod]
public override async Task IndexOf_ReturnsCorrectIndex() => await base.IndexOf_ReturnsCorrectIndex();
[TestMethod]
public override void IndexOf_ById_ReturnsCorrectIndex() => base.IndexOf_ById_ReturnsCorrectIndex();
[TestMethod]
public override void TryGetIndex_ReturnsTrue_WhenExists() => base.TryGetIndex_ReturnsTrue_WhenExists();
[TestMethod]
public override async Task TryGetValue_ReturnsItem_WhenExists() => await base.TryGetValue_ReturnsItem_WhenExists();
[TestMethod]
public override void TryGetValue_ReturnsFalse_WhenNotExists() => base.TryGetValue_ReturnsFalse_WhenNotExists();
[TestMethod]
public override async Task CopyTo_CopiesAllItems() => await base.CopyTo_CopiesAllItems();
[TestMethod]
public override async Task GetEnumerator_EnumeratesAllItems() => await base.GetEnumerator_EnumeratesAllItems();
[TestMethod]
public override async Task AsReadOnly_ReturnsReadOnlyCollection() => await base.AsReadOnly_ReturnsReadOnlyCollection();
[TestMethod]
public override async Task SetWorkingReferenceList_SetsNewInnerList() => await base.SetWorkingReferenceList_SetsNewInnerList();
[TestMethod]
public override async Task GetReferenceInnerList_ReturnsInnerList() => await base.GetReferenceInnerList_ReturnsInnerList();
[TestMethod]
public override void IsSyncing_IsFalse_Initially() => base.IsSyncing_IsFalse_Initially();
[TestMethod]
public override async Task OnSyncingStateChanged_Fires_DuringLoad() => await base.OnSyncingStateChanged_Fires_DuringLoad();
[TestMethod]
public override void ContextIds_CanBeSetAndRetrieved() => base.ContextIds_CanBeSetAndRetrieved();
[TestMethod]
public override void FilterText_CanBeSetAndRetrieved() => base.FilterText_CanBeSetAndRetrieved();
[TestMethod]
public override void IList_Add_ReturnsCorrectIndex() => base.IList_Add_ReturnsCorrectIndex();
[TestMethod]
public override void IList_Contains_WorksCorrectly() => base.IList_Contains_WorksCorrectly();
[TestMethod]
public override void IList_IndexOf_WorksCorrectly() => base.IList_IndexOf_WorksCorrectly();
[TestMethod]
public override void IList_Insert_WorksCorrectly() => base.IList_Insert_WorksCorrectly();
[TestMethod]
public override void IList_Remove_WorksCorrectly() => base.IList_Remove_WorksCorrectly();
[TestMethod]
public override void IList_Indexer_GetSet_WorksCorrectly() => base.IList_Indexer_GetSet_WorksCorrectly();
[TestMethod]
public override void ICollection_CopyTo_WorksCorrectly() => base.ICollection_CopyTo_WorksCorrectly();
[TestMethod]
public override void IsSynchronized_ReturnsTrue() => base.IsSynchronized_ReturnsTrue();
[TestMethod]
public override void SyncRoot_IsNotNull() => base.SyncRoot_IsNotNull();
[TestMethod]
public override void IsFixedSize_ReturnsFalse() => base.IsFixedSize_ReturnsFalse();
[TestMethod]
public override void IsReadOnly_ReturnsFalse() => base.IsReadOnly_ReturnsFalse();
[TestMethod]
public override async Task Indexer_OutOfRange_ThrowsException() => await base.Indexer_OutOfRange_ThrowsException();
[TestMethod]
public override void Add_ThenRemove_ClearsTracking() => base.Add_ThenRemove_ClearsTracking();
[TestMethod]
public override async Task ComplexWorkflow_AddUpdateRemoveSave() => await base.ComplexWorkflow_AddUpdateRemoveSave();
}

View File

@ -0,0 +1,163 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.SignalRs;
namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
[TestClass]
public class SignalRDataSourceTests_List_Json : SignalRDataSourceTestBase<TestOrderItemListDataSource, List<TestOrderItem>>
{
protected override AcSerializerOptions SerializerOption => new AcJsonSerializerOptions();
protected override TestOrderItemListDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags)
=> new(client, crudTags);
[TestMethod]
public override async Task LoadDataSource_ReturnsAllItems() => await base.LoadDataSource_ReturnsAllItems();
[TestMethod]
public override async Task LoadDataSource_ClearsChangeTracking_ByDefault() => await base.LoadDataSource_ClearsChangeTracking_ByDefault();
[TestMethod]
public override async Task LoadDataSource_PreservesChangeTracking_WhenFalse() => await base.LoadDataSource_PreservesChangeTracking_WhenFalse();
[TestMethod]
public override async Task LoadDataSource_InvokesOnDataSourceLoaded() => await base.LoadDataSource_InvokesOnDataSourceLoaded();
[TestMethod]
public override async Task LoadDataSource_MultipleCalls_RefreshesData() => await base.LoadDataSource_MultipleCalls_RefreshesData();
[TestMethod]
public override async Task LoadDataSourceAsync_LoadsDataViaCallback() => await base.LoadDataSourceAsync_LoadsDataViaCallback();
[TestMethod]
public override async Task LoadItem_ReturnsSingleItem() => await base.LoadItem_ReturnsSingleItem();
[TestMethod]
public override async Task LoadItem_AddsToDataSource_WhenNotExists() => await base.LoadItem_AddsToDataSource_WhenNotExists();
[TestMethod]
public override async Task LoadItem_UpdatesExisting_WhenAlreadyLoaded() => await base.LoadItem_UpdatesExisting_WhenAlreadyLoaded();
[TestMethod]
public override async Task LoadItem_InvokesOnDataSourceItemChanged() => await base.LoadItem_InvokesOnDataSourceItemChanged();
[TestMethod]
public override async Task LoadItem_ReturnsNull_WhenNotFound() => await base.LoadItem_ReturnsNull_WhenNotFound();
[TestMethod]
public override async Task Add_WithAutoSave_AddsItem() => await base.Add_WithAutoSave_AddsItem();
[TestMethod]
public override void Add_WithoutAutoSave_AddsToTrackingOnly() => base.Add_WithoutAutoSave_AddsToTrackingOnly();
[TestMethod]
public override void Add_DuplicateId_ThrowsException() => base.Add_DuplicateId_ThrowsException();
[TestMethod]
public override void Add_DefaultId_ThrowsException() => base.Add_DefaultId_ThrowsException();
[TestMethod]
public override void AddRange_AddsMultipleItems() => base.AddRange_AddsMultipleItems();
[TestMethod]
public override async Task AddOrUpdate_AddsNew_WhenNotExists() => await base.AddOrUpdate_AddsNew_WhenNotExists();
[TestMethod]
public override async Task AddOrUpdate_UpdatesExisting_WhenExists() => await base.AddOrUpdate_UpdatesExisting_WhenExists();
[TestMethod]
public override void Insert_AtIndex_InsertsCorrectly() => base.Insert_AtIndex_InsertsCorrectly();
[TestMethod]
public override async Task Insert_WithAutoSave_SavesImmediately() => await base.Insert_WithAutoSave_SavesImmediately();
[TestMethod]
public override async Task Update_ByIndex_UpdatesCorrectly() => await base.Update_ByIndex_UpdatesCorrectly();
[TestMethod]
public override async Task Update_ByItem_UpdatesCorrectly() => await base.Update_ByItem_UpdatesCorrectly();
[TestMethod]
public override void Indexer_Set_TracksUpdate() => base.Indexer_Set_TracksUpdate();
[TestMethod]
public override async Task Remove_ById_RemovesItem() => await base.Remove_ById_RemovesItem();
[TestMethod]
public override async Task Remove_ByItem_RemovesItem() => await base.Remove_ByItem_RemovesItem();
[TestMethod]
public override void Remove_WithoutAutoSave_TracksRemoval() => base.Remove_WithoutAutoSave_TracksRemoval();
[TestMethod]
public override void RemoveAt_RemovesCorrectItem() => base.RemoveAt_RemovesCorrectItem();
[TestMethod]
public override async Task RemoveAt_WithAutoSave_SavesImmediately() => await base.RemoveAt_WithAutoSave_SavesImmediately();
[TestMethod]
public override void TryRemove_ReturnsTrue_WhenExists() => base.TryRemove_ReturnsTrue_WhenExists();
[TestMethod]
public override void TryRemove_ReturnsFalse_WhenNotExists() => base.TryRemove_ReturnsFalse_WhenNotExists();
[TestMethod]
public override async Task SaveChanges_SavesTrackedItems() => await base.SaveChanges_SavesTrackedItems();
[TestMethod]
public override async Task SaveChangesAsync_ClearsTracking() => await base.SaveChangesAsync_ClearsTracking();
[TestMethod]
public override async Task SaveItem_ById_SavesSpecificItem() => await base.SaveItem_ById_SavesSpecificItem();
[TestMethod]
public override async Task SaveItem_WithTrackingState_SavesCorrectly() => await base.SaveItem_WithTrackingState_SavesCorrectly();
[TestMethod]
public override void SetTrackingStateToUpdate_MarksItemForUpdate() => base.SetTrackingStateToUpdate_MarksItemForUpdate();
[TestMethod]
public override void SetTrackingStateToUpdate_DoesNotChangeAddState() => base.SetTrackingStateToUpdate_DoesNotChangeAddState();
[TestMethod]
public override void TryGetTrackingItem_ReturnsTrue_WhenTracked() => base.TryGetTrackingItem_ReturnsTrue_WhenTracked();
[TestMethod]
public override void TryGetTrackingItem_ReturnsFalse_WhenNotTracked() => base.TryGetTrackingItem_ReturnsFalse_WhenNotTracked();
[TestMethod]
public override void TryRollbackItem_RevertsAddedItem() => base.TryRollbackItem_RevertsAddedItem();
[TestMethod]
public override async Task TryRollbackItem_RevertsUpdatedItem() => await base.TryRollbackItem_RevertsUpdatedItem();
[TestMethod]
public override void Rollback_RevertsAllChanges() => base.Rollback_RevertsAllChanges();
[TestMethod]
public override async Task Count_ReturnsCorrectValue() => await base.Count_ReturnsCorrectValue();
[TestMethod]
public override void Clear_RemovesAllItems() => base.Clear_RemovesAllItems();
[TestMethod]
public override void Clear_WithoutClearingTracking_PreservesTracking() => base.Clear_WithoutClearingTracking_PreservesTracking();
[TestMethod]
public override async Task Contains_ReturnsTrue_WhenItemExists() => await base.Contains_ReturnsTrue_WhenItemExists();
[TestMethod]
public override void Contains_ReturnsFalse_WhenItemNotExists() => base.Contains_ReturnsFalse_WhenItemNotExists();
[TestMethod]
public override async Task IndexOf_ReturnsCorrectIndex() => await base.IndexOf_ReturnsCorrectIndex();
[TestMethod]
public override void IndexOf_ById_ReturnsCorrectIndex() => base.IndexOf_ById_ReturnsCorrectIndex();
[TestMethod]
public override void TryGetIndex_ReturnsTrue_WhenExists() => base.TryGetIndex_ReturnsTrue_WhenExists();
[TestMethod]
public override async Task TryGetValue_ReturnsItem_WhenExists() => await base.TryGetValue_ReturnsItem_WhenExists();
[TestMethod]
public override void TryGetValue_ReturnsFalse_WhenNotExists() => base.TryGetValue_ReturnsFalse_WhenNotExists();
[TestMethod]
public override async Task CopyTo_CopiesAllItems() => await base.CopyTo_CopiesAllItems();
[TestMethod]
public override async Task GetEnumerator_EnumeratesAllItems() => await base.GetEnumerator_EnumeratesAllItems();
[TestMethod]
public override async Task AsReadOnly_ReturnsReadOnlyCollection() => await base.AsReadOnly_ReturnsReadOnlyCollection();
[TestMethod]
public override async Task SetWorkingReferenceList_SetsNewInnerList() => await base.SetWorkingReferenceList_SetsNewInnerList();
[TestMethod]
public override async Task GetReferenceInnerList_ReturnsInnerList() => await base.GetReferenceInnerList_ReturnsInnerList();
[TestMethod]
public override void IsSyncing_IsFalse_Initially() => base.IsSyncing_IsFalse_Initially();
[TestMethod]
public override async Task OnSyncingStateChanged_Fires_DuringLoad() => await base.OnSyncingStateChanged_Fires_DuringLoad();
[TestMethod]
public override void ContextIds_CanBeSetAndRetrieved() => base.ContextIds_CanBeSetAndRetrieved();
[TestMethod]
public override void FilterText_CanBeSetAndRetrieved() => base.FilterText_CanBeSetAndRetrieved();
[TestMethod]
public override void IList_Add_ReturnsCorrectIndex() => base.IList_Add_ReturnsCorrectIndex();
[TestMethod]
public override void IList_Contains_WorksCorrectly() => base.IList_Contains_WorksCorrectly();
[TestMethod]
public override void IList_IndexOf_WorksCorrectly() => base.IList_IndexOf_WorksCorrectly();
[TestMethod]
public override void IList_Insert_WorksCorrectly() => base.IList_Insert_WorksCorrectly();
[TestMethod]
public override void IList_Remove_WorksCorrectly() => base.IList_Remove_WorksCorrectly();
[TestMethod]
public override void IList_Indexer_GetSet_WorksCorrectly() => base.IList_Indexer_GetSet_WorksCorrectly();
[TestMethod]
public override void ICollection_CopyTo_WorksCorrectly() => base.ICollection_CopyTo_WorksCorrectly();
[TestMethod]
public override void IsSynchronized_ReturnsTrue() => base.IsSynchronized_ReturnsTrue();
[TestMethod]
public override void SyncRoot_IsNotNull() => base.SyncRoot_IsNotNull();
[TestMethod]
public override void IsFixedSize_ReturnsFalse() => base.IsFixedSize_ReturnsFalse();
[TestMethod]
public override void IsReadOnly_ReturnsFalse() => base.IsReadOnly_ReturnsFalse();
[TestMethod]
public override async Task Indexer_OutOfRange_ThrowsException() => await base.Indexer_OutOfRange_ThrowsException();
[TestMethod]
public override void Add_ThenRemove_ClearsTracking() => base.Add_ThenRemove_ClearsTracking();
[TestMethod]
public override async Task ComplexWorkflow_AddUpdateRemoveSave() => await base.ComplexWorkflow_AddUpdateRemoveSave();
}

View File

@ -0,0 +1,164 @@
using AyCode.Core.Helpers;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.SignalRs;
namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
[TestClass]
public class SignalRDataSourceTests_Observable_Binary : SignalRDataSourceTestBase<TestOrderItemObservableDataSource, AcObservableCollection<TestOrderItem>>
{
protected override AcSerializerOptions SerializerOption => new AcBinarySerializerOptions();
protected override TestOrderItemObservableDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags)
=> new(client, crudTags);
[TestMethod]
public override async Task LoadDataSource_ReturnsAllItems() => await base.LoadDataSource_ReturnsAllItems();
[TestMethod]
public override async Task LoadDataSource_ClearsChangeTracking_ByDefault() => await base.LoadDataSource_ClearsChangeTracking_ByDefault();
[TestMethod]
public override async Task LoadDataSource_PreservesChangeTracking_WhenFalse() => await base.LoadDataSource_PreservesChangeTracking_WhenFalse();
[TestMethod]
public override async Task LoadDataSource_InvokesOnDataSourceLoaded() => await base.LoadDataSource_InvokesOnDataSourceLoaded();
[TestMethod]
public override async Task LoadDataSource_MultipleCalls_RefreshesData() => await base.LoadDataSource_MultipleCalls_RefreshesData();
[TestMethod]
public override async Task LoadDataSourceAsync_LoadsDataViaCallback() => await base.LoadDataSourceAsync_LoadsDataViaCallback();
[TestMethod]
public override async Task LoadItem_ReturnsSingleItem() => await base.LoadItem_ReturnsSingleItem();
[TestMethod]
public override async Task LoadItem_AddsToDataSource_WhenNotExists() => await base.LoadItem_AddsToDataSource_WhenNotExists();
[TestMethod]
public override async Task LoadItem_UpdatesExisting_WhenAlreadyLoaded() => await base.LoadItem_UpdatesExisting_WhenAlreadyLoaded();
[TestMethod]
public override async Task LoadItem_InvokesOnDataSourceItemChanged() => await base.LoadItem_InvokesOnDataSourceItemChanged();
[TestMethod]
public override async Task LoadItem_ReturnsNull_WhenNotFound() => await base.LoadItem_ReturnsNull_WhenNotFound();
[TestMethod]
public override async Task Add_WithAutoSave_AddsItem() => await base.Add_WithAutoSave_AddsItem();
[TestMethod]
public override void Add_WithoutAutoSave_AddsToTrackingOnly() => base.Add_WithoutAutoSave_AddsToTrackingOnly();
[TestMethod]
public override void Add_DuplicateId_ThrowsException() => base.Add_DuplicateId_ThrowsException();
[TestMethod]
public override void Add_DefaultId_ThrowsException() => base.Add_DefaultId_ThrowsException();
[TestMethod]
public override void AddRange_AddsMultipleItems() => base.AddRange_AddsMultipleItems();
[TestMethod]
public override async Task AddOrUpdate_AddsNew_WhenNotExists() => await base.AddOrUpdate_AddsNew_WhenNotExists();
[TestMethod]
public override async Task AddOrUpdate_UpdatesExisting_WhenExists() => await base.AddOrUpdate_UpdatesExisting_WhenExists();
[TestMethod]
public override void Insert_AtIndex_InsertsCorrectly() => base.Insert_AtIndex_InsertsCorrectly();
[TestMethod]
public override async Task Insert_WithAutoSave_SavesImmediately() => await base.Insert_WithAutoSave_SavesImmediately();
[TestMethod]
public override async Task Update_ByIndex_UpdatesCorrectly() => await base.Update_ByIndex_UpdatesCorrectly();
[TestMethod]
public override async Task Update_ByItem_UpdatesCorrectly() => await base.Update_ByItem_UpdatesCorrectly();
[TestMethod]
public override void Indexer_Set_TracksUpdate() => base.Indexer_Set_TracksUpdate();
[TestMethod]
public override async Task Remove_ById_RemovesItem() => await base.Remove_ById_RemovesItem();
[TestMethod]
public override async Task Remove_ByItem_RemovesItem() => await base.Remove_ByItem_RemovesItem();
[TestMethod]
public override void Remove_WithoutAutoSave_TracksRemoval() => base.Remove_WithoutAutoSave_TracksRemoval();
[TestMethod]
public override void RemoveAt_RemovesCorrectItem() => base.RemoveAt_RemovesCorrectItem();
[TestMethod]
public override async Task RemoveAt_WithAutoSave_SavesImmediately() => await base.RemoveAt_WithAutoSave_SavesImmediately();
[TestMethod]
public override void TryRemove_ReturnsTrue_WhenExists() => base.TryRemove_ReturnsTrue_WhenExists();
[TestMethod]
public override void TryRemove_ReturnsFalse_WhenNotExists() => base.TryRemove_ReturnsFalse_WhenNotExists();
[TestMethod]
public override async Task SaveChanges_SavesTrackedItems() => await base.SaveChanges_SavesTrackedItems();
[TestMethod]
public override async Task SaveChangesAsync_ClearsTracking() => await base.SaveChangesAsync_ClearsTracking();
[TestMethod]
public override async Task SaveItem_ById_SavesSpecificItem() => await base.SaveItem_ById_SavesSpecificItem();
[TestMethod]
public override async Task SaveItem_WithTrackingState_SavesCorrectly() => await base.SaveItem_WithTrackingState_SavesCorrectly();
[TestMethod]
public override void SetTrackingStateToUpdate_MarksItemForUpdate() => base.SetTrackingStateToUpdate_MarksItemForUpdate();
[TestMethod]
public override void SetTrackingStateToUpdate_DoesNotChangeAddState() => base.SetTrackingStateToUpdate_DoesNotChangeAddState();
[TestMethod]
public override void TryGetTrackingItem_ReturnsTrue_WhenTracked() => base.TryGetTrackingItem_ReturnsTrue_WhenTracked();
[TestMethod]
public override void TryGetTrackingItem_ReturnsFalse_WhenNotTracked() => base.TryGetTrackingItem_ReturnsFalse_WhenNotTracked();
[TestMethod]
public override void TryRollbackItem_RevertsAddedItem() => base.TryRollbackItem_RevertsAddedItem();
[TestMethod]
public override async Task TryRollbackItem_RevertsUpdatedItem() => await base.TryRollbackItem_RevertsUpdatedItem();
[TestMethod]
public override void Rollback_RevertsAllChanges() => base.Rollback_RevertsAllChanges();
[TestMethod]
public override async Task Count_ReturnsCorrectValue() => await base.Count_ReturnsCorrectValue();
[TestMethod]
public override void Clear_RemovesAllItems() => base.Clear_RemovesAllItems();
[TestMethod]
public override void Clear_WithoutClearingTracking_PreservesTracking() => base.Clear_WithoutClearingTracking_PreservesTracking();
[TestMethod]
public override async Task Contains_ReturnsTrue_WhenItemExists() => await base.Contains_ReturnsTrue_WhenItemExists();
[TestMethod]
public override void Contains_ReturnsFalse_WhenItemNotExists() => base.Contains_ReturnsFalse_WhenItemNotExists();
[TestMethod]
public override async Task IndexOf_ReturnsCorrectIndex() => await base.IndexOf_ReturnsCorrectIndex();
[TestMethod]
public override void IndexOf_ById_ReturnsCorrectIndex() => base.IndexOf_ById_ReturnsCorrectIndex();
[TestMethod]
public override void TryGetIndex_ReturnsTrue_WhenExists() => base.TryGetIndex_ReturnsTrue_WhenExists();
[TestMethod]
public override async Task TryGetValue_ReturnsItem_WhenExists() => await base.TryGetValue_ReturnsItem_WhenExists();
[TestMethod]
public override void TryGetValue_ReturnsFalse_WhenNotExists() => base.TryGetValue_ReturnsFalse_WhenNotExists();
[TestMethod]
public override async Task CopyTo_CopiesAllItems() => await base.CopyTo_CopiesAllItems();
[TestMethod]
public override async Task GetEnumerator_EnumeratesAllItems() => await base.GetEnumerator_EnumeratesAllItems();
[TestMethod]
public override async Task AsReadOnly_ReturnsReadOnlyCollection() => await base.AsReadOnly_ReturnsReadOnlyCollection();
[TestMethod]
public override async Task SetWorkingReferenceList_SetsNewInnerList() => await base.SetWorkingReferenceList_SetsNewInnerList();
[TestMethod]
public override async Task GetReferenceInnerList_ReturnsInnerList() => await base.GetReferenceInnerList_ReturnsInnerList();
[TestMethod]
public override void IsSyncing_IsFalse_Initially() => base.IsSyncing_IsFalse_Initially();
[TestMethod]
public override async Task OnSyncingStateChanged_Fires_DuringLoad() => await base.OnSyncingStateChanged_Fires_DuringLoad();
[TestMethod]
public override void ContextIds_CanBeSetAndRetrieved() => base.ContextIds_CanBeSetAndRetrieved();
[TestMethod]
public override void FilterText_CanBeSetAndRetrieved() => base.FilterText_CanBeSetAndRetrieved();
[TestMethod]
public override void IList_Add_ReturnsCorrectIndex() => base.IList_Add_ReturnsCorrectIndex();
[TestMethod]
public override void IList_Contains_WorksCorrectly() => base.IList_Contains_WorksCorrectly();
[TestMethod]
public override void IList_IndexOf_WorksCorrectly() => base.IList_IndexOf_WorksCorrectly();
[TestMethod]
public override void IList_Insert_WorksCorrectly() => base.IList_Insert_WorksCorrectly();
[TestMethod]
public override void IList_Remove_WorksCorrectly() => base.IList_Remove_WorksCorrectly();
[TestMethod]
public override void IList_Indexer_GetSet_WorksCorrectly() => base.IList_Indexer_GetSet_WorksCorrectly();
[TestMethod]
public override void ICollection_CopyTo_WorksCorrectly() => base.ICollection_CopyTo_WorksCorrectly();
[TestMethod]
public override void IsSynchronized_ReturnsTrue() => base.IsSynchronized_ReturnsTrue();
[TestMethod]
public override void SyncRoot_IsNotNull() => base.SyncRoot_IsNotNull();
[TestMethod]
public override void IsFixedSize_ReturnsFalse() => base.IsFixedSize_ReturnsFalse();
[TestMethod]
public override void IsReadOnly_ReturnsFalse() => base.IsReadOnly_ReturnsFalse();
[TestMethod]
public override async Task Indexer_OutOfRange_ThrowsException() => await base.Indexer_OutOfRange_ThrowsException();
[TestMethod]
public override void Add_ThenRemove_ClearsTracking() => base.Add_ThenRemove_ClearsTracking();
[TestMethod]
public override async Task ComplexWorkflow_AddUpdateRemoveSave() => await base.ComplexWorkflow_AddUpdateRemoveSave();
}

View File

@ -0,0 +1,163 @@
using AyCode.Core.Helpers;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.SignalRs;
namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
[TestClass]
public class SignalRDataSourceTests_Observable_Json : SignalRDataSourceTestBase<TestOrderItemObservableDataSource, AcObservableCollection<TestOrderItem>>
{
protected override AcSerializerOptions SerializerOption => new AcJsonSerializerOptions();
protected override TestOrderItemObservableDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags)
=> new(client, crudTags);
[TestMethod]
public override async Task LoadDataSource_ReturnsAllItems() => await base.LoadDataSource_ReturnsAllItems();
[TestMethod]
public override async Task LoadDataSource_ClearsChangeTracking_ByDefault() => await base.LoadDataSource_ClearsChangeTracking_ByDefault();
[TestMethod]
public override async Task LoadDataSource_PreservesChangeTracking_WhenFalse() => await base.LoadDataSource_PreservesChangeTracking_WhenFalse();
[TestMethod]
public override async Task LoadDataSource_InvokesOnDataSourceLoaded() => await base.LoadDataSource_InvokesOnDataSourceLoaded();
[TestMethod]
public override async Task LoadDataSource_MultipleCalls_RefreshesData() => await base.LoadDataSource_MultipleCalls_RefreshesData();
[TestMethod]
public override async Task LoadDataSourceAsync_LoadsDataViaCallback() => await base.LoadDataSourceAsync_LoadsDataViaCallback();
[TestMethod]
public override async Task LoadItem_ReturnsSingleItem() => await base.LoadItem_ReturnsSingleItem();
[TestMethod]
public override async Task LoadItem_AddsToDataSource_WhenNotExists() => await base.LoadItem_AddsToDataSource_WhenNotExists();
[TestMethod]
public override async Task LoadItem_UpdatesExisting_WhenAlreadyLoaded() => await base.LoadItem_UpdatesExisting_WhenAlreadyLoaded();
[TestMethod]
public override async Task LoadItem_InvokesOnDataSourceItemChanged() => await base.LoadItem_InvokesOnDataSourceItemChanged();
[TestMethod]
public override async Task LoadItem_ReturnsNull_WhenNotFound() => await base.LoadItem_ReturnsNull_WhenNotFound();
[TestMethod]
public override async Task Add_WithAutoSave_AddsItem() => await base.Add_WithAutoSave_AddsItem();
[TestMethod]
public override void Add_WithoutAutoSave_AddsToTrackingOnly() => base.Add_WithoutAutoSave_AddsToTrackingOnly();
[TestMethod]
public override void Add_DuplicateId_ThrowsException() => base.Add_DuplicateId_ThrowsException();
[TestMethod]
public override void Add_DefaultId_ThrowsException() => base.Add_DefaultId_ThrowsException();
[TestMethod]
public override void AddRange_AddsMultipleItems() => base.AddRange_AddsMultipleItems();
[TestMethod]
public override async Task AddOrUpdate_AddsNew_WhenNotExists() => await base.AddOrUpdate_AddsNew_WhenNotExists();
[TestMethod]
public override async Task AddOrUpdate_UpdatesExisting_WhenExists() => await base.AddOrUpdate_UpdatesExisting_WhenExists();
[TestMethod]
public override void Insert_AtIndex_InsertsCorrectly() => base.Insert_AtIndex_InsertsCorrectly();
[TestMethod]
public override async Task Insert_WithAutoSave_SavesImmediately() => await base.Insert_WithAutoSave_SavesImmediately();
[TestMethod]
public override async Task Update_ByIndex_UpdatesCorrectly() => await base.Update_ByIndex_UpdatesCorrectly();
[TestMethod]
public override async Task Update_ByItem_UpdatesCorrectly() => await base.Update_ByItem_UpdatesCorrectly();
[TestMethod]
public override void Indexer_Set_TracksUpdate() => base.Indexer_Set_TracksUpdate();
[TestMethod]
public override async Task Remove_ById_RemovesItem() => await base.Remove_ById_RemovesItem();
[TestMethod]
public override async Task Remove_ByItem_RemovesItem() => await base.Remove_ByItem_RemovesItem();
[TestMethod]
public override void Remove_WithoutAutoSave_TracksRemoval() => base.Remove_WithoutAutoSave_TracksRemoval();
[TestMethod]
public override void RemoveAt_RemovesCorrectItem() => base.RemoveAt_RemovesCorrectItem();
[TestMethod]
public override async Task RemoveAt_WithAutoSave_SavesImmediately() => await base.RemoveAt_WithAutoSave_SavesImmediately();
[TestMethod]
public override void TryRemove_ReturnsTrue_WhenExists() => base.TryRemove_ReturnsTrue_WhenExists();
[TestMethod]
public override void TryRemove_ReturnsFalse_WhenNotExists() => base.TryRemove_ReturnsFalse_WhenNotExists();
[TestMethod]
public override async Task SaveChanges_SavesTrackedItems() => await base.SaveChanges_SavesTrackedItems();
[TestMethod]
public override async Task SaveChangesAsync_ClearsTracking() => await base.SaveChangesAsync_ClearsTracking();
[TestMethod]
public override async Task SaveItem_ById_SavesSpecificItem() => await base.SaveItem_ById_SavesSpecificItem();
[TestMethod]
public override async Task SaveItem_WithTrackingState_SavesCorrectly() => await base.SaveItem_WithTrackingState_SavesCorrectly();
[TestMethod]
public override void SetTrackingStateToUpdate_MarksItemForUpdate() => base.SetTrackingStateToUpdate_MarksItemForUpdate();
[TestMethod]
public override void SetTrackingStateToUpdate_DoesNotChangeAddState() => base.SetTrackingStateToUpdate_DoesNotChangeAddState();
[TestMethod]
public override void TryGetTrackingItem_ReturnsTrue_WhenTracked() => base.TryGetTrackingItem_ReturnsTrue_WhenTracked();
[TestMethod]
public override void TryGetTrackingItem_ReturnsFalse_WhenNotTracked() => base.TryGetTrackingItem_ReturnsFalse_WhenNotTracked();
[TestMethod]
public override void TryRollbackItem_RevertsAddedItem() => base.TryRollbackItem_RevertsAddedItem();
[TestMethod]
public override async Task TryRollbackItem_RevertsUpdatedItem() => await base.TryRollbackItem_RevertsUpdatedItem();
[TestMethod]
public override void Rollback_RevertsAllChanges() => base.Rollback_RevertsAllChanges();
[TestMethod]
public override async Task Count_ReturnsCorrectValue() => await base.Count_ReturnsCorrectValue();
[TestMethod]
public override void Clear_RemovesAllItems() => base.Clear_RemovesAllItems();
[TestMethod]
public override void Clear_WithoutClearingTracking_PreservesTracking() => base.Clear_WithoutClearingTracking_PreservesTracking();
[TestMethod]
public override async Task Contains_ReturnsTrue_WhenItemExists() => await base.Contains_ReturnsTrue_WhenItemExists();
[TestMethod]
public override void Contains_ReturnsFalse_WhenItemNotExists() => base.Contains_ReturnsFalse_WhenItemNotExists();
[TestMethod]
public override async Task IndexOf_ReturnsCorrectIndex() => await base.IndexOf_ReturnsCorrectIndex();
[TestMethod]
public override void IndexOf_ById_ReturnsCorrectIndex() => base.IndexOf_ById_ReturnsCorrectIndex();
[TestMethod]
public override void TryGetIndex_ReturnsTrue_WhenExists() => base.TryGetIndex_ReturnsTrue_WhenExists();
[TestMethod]
public override async Task TryGetValue_ReturnsItem_WhenExists() => await base.TryGetValue_ReturnsItem_WhenExists();
[TestMethod]
public override void TryGetValue_ReturnsFalse_WhenNotExists() => base.TryGetValue_ReturnsFalse_WhenNotExists();
[TestMethod]
public override async Task CopyTo_CopiesAllItems() => await base.CopyTo_CopiesAllItems();
[TestMethod]
public override async Task GetEnumerator_EnumeratesAllItems() => await base.GetEnumerator_EnumeratesAllItems();
[TestMethod]
public override async Task AsReadOnly_ReturnsReadOnlyCollection() => await base.AsReadOnly_ReturnsReadOnlyCollection();
[TestMethod]
public override async Task SetWorkingReferenceList_SetsNewInnerList() => await base.SetWorkingReferenceList_SetsNewInnerList();
[TestMethod]
public override async Task GetReferenceInnerList_ReturnsInnerList() => await base.GetReferenceInnerList_ReturnsInnerList();
[TestMethod]
public override void IsSyncing_IsFalse_Initially() => base.IsSyncing_IsFalse_Initially();
[TestMethod]
public override async Task OnSyncingStateChanged_Fires_DuringLoad() => await base.OnSyncingStateChanged_Fires_DuringLoad();
[TestMethod]
public override void ContextIds_CanBeSetAndRetrieved() => base.ContextIds_CanBeSetAndRetrieved();
[TestMethod]
public override void FilterText_CanBeSetAndRetrieved() => base.FilterText_CanBeSetAndRetrieved();
[TestMethod]
public override void IList_Add_ReturnsCorrectIndex() => base.IList_Add_ReturnsCorrectIndex();
[TestMethod]
public override void IList_Contains_WorksCorrectly() => base.IList_Contains_WorksCorrectly();
[TestMethod]
public override void IList_IndexOf_WorksCorrectly() => base.IList_IndexOf_WorksCorrectly();
[TestMethod]
public override void IList_Insert_WorksCorrectly() => base.IList_Insert_WorksCorrectly();
[TestMethod]
public override void IList_Remove_WorksCorrectly() => base.IList_Remove_WorksCorrectly();
[TestMethod]
public override void IList_Indexer_GetSet_WorksCorrectly() => base.IList_Indexer_GetSet_WorksCorrectly();
[TestMethod]
public override void ICollection_CopyTo_WorksCorrectly() => base.ICollection_CopyTo_WorksCorrectly();
[TestMethod]
public override void IsSynchronized_ReturnsTrue() => base.IsSynchronized_ReturnsTrue();
[TestMethod]
public override void SyncRoot_IsNotNull() => base.SyncRoot_IsNotNull();
[TestMethod]
public override void IsFixedSize_ReturnsFalse() => base.IsFixedSize_ReturnsFalse();
[TestMethod]
public override void IsReadOnly_ReturnsFalse() => base.IsReadOnly_ReturnsFalse();
[TestMethod]
public override async Task Indexer_OutOfRange_ThrowsException() => await base.Indexer_OutOfRange_ThrowsException();
[TestMethod]
public override void Add_ThenRemove_ClearsTracking() => base.Add_ThenRemove_ClearsTracking();
[TestMethod]
public override async Task ComplexWorkflow_AddUpdateRemoveSave() => await base.ComplexWorkflow_AddUpdateRemoveSave();
}

View File

@ -0,0 +1,11 @@
using AyCode.Core.Tests.TestModels;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs;
namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
public class TestOrderItemListDataSource : AcSignalRDataSource<TestOrderItem, int, List<TestOrderItem>>
{
public TestOrderItemListDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags crudTags)
: base(signalRClient, crudTags) { }
}

View File

@ -0,0 +1,12 @@
using AyCode.Core.Helpers;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs;
namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
public class TestOrderItemObservableDataSource : AcSignalRDataSource<TestOrderItem, int, AcObservableCollection<TestOrderItem>>
{
public TestOrderItemObservableDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags crudTags)
: base(signalRClient, crudTags) { }
}

View File

@ -1,6 +1,8 @@
using System.Globalization;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.SignalRs;
using static AyCode.Core.Tests.Serialization.AcSerializerModels;
using AyCode.Core.Tests.Serialization;
namespace AyCode.Services.Server.Tests.SignalRs;
@ -468,4 +470,72 @@ public class TestSignalRService2
}
#endregion
#region StockTaking Production Bug Reproduction
/// <summary>
/// Simulates the exact production scenario from FruitBank GetStockTakings(false).
/// Returns data from actual database records to reproduce the bug.
/// Uses the REAL StockTaking model from StockTakingTestModels.cs
/// </summary>
[SignalR(TestSignalRTags.GetStockTakings)]
public List<StockTaking> GetStockTakings(bool loadRelations)
{
// Exact data from production database:
return
[
new StockTaking
{
Id = 7,
StartDateTime = new DateTime(2025, 12, 3, 8, 55, 43, 539, DateTimeKind.Utc),
IsClosed = false, // This is the key - IsClosed=false gets skipped by serializer!
Creator = 6,
Created = new DateTime(2025, 12, 3, 7, 55, 43, 571, DateTimeKind.Utc),
Modified = new DateTime(2025, 12, 3, 7, 55, 43, 571, DateTimeKind.Utc),
StockTakingItems = loadRelations ? [] : null
},
new StockTaking
{
Id = 6,
StartDateTime = new DateTime(2025, 12, 2, 8, 21, 26, 439, DateTimeKind.Utc),
IsClosed = true,
Creator = 6,
Created = new DateTime(2025, 12, 2, 7, 21, 26, 468, DateTimeKind.Utc),
Modified = new DateTime(2025, 12, 2, 7, 21, 26, 468, DateTimeKind.Utc),
StockTakingItems = loadRelations ? [] : null
},
new StockTaking
{
Id = 3,
StartDateTime = new DateTime(2025, 11, 30, 14, 1, 55, 663, DateTimeKind.Utc),
IsClosed = true,
Creator = 6,
Created = new DateTime(2025, 11, 30, 13, 1, 55, 692, DateTimeKind.Utc),
Modified = new DateTime(2025, 11, 30, 13, 1, 55, 692, DateTimeKind.Utc),
StockTakingItems = loadRelations ? [] : null
},
new StockTaking
{
Id = 2,
StartDateTime = new DateTime(2025, 11, 30, 8, 20, 2, 182, DateTimeKind.Utc),
IsClosed = true,
Creator = 6,
Created = new DateTime(2025, 11, 30, 7, 20, 3, 331, DateTimeKind.Utc),
Modified = new DateTime(2025, 11, 30, 7, 20, 3, 331, DateTimeKind.Utc),
StockTakingItems = loadRelations ? [] : null
},
new StockTaking
{
Id = 1,
StartDateTime = new DateTime(2025, 11, 30, 8, 18, 59, 693, DateTimeKind.Utc),
IsClosed = true,
Creator = 6,
Created = new DateTime(2025, 11, 30, 7, 19, 1, 849, DateTimeKind.Utc),
Modified = new DateTime(2025, 11, 30, 7, 19, 1, 877, DateTimeKind.Utc),
StockTakingItems = loadRelations ? [] : null
}
];
}
#endregion
}

View File

@ -88,4 +88,7 @@ public abstract class TestSignalRTags : AcSignalRTags
public const int DataSourceAdd = 302;
public const int DataSourceUpdate = 303;
public const int DataSourceRemove = 304;
// StockTaking production bug reproduction
public const int GetStockTakings = 400;
}

View File

@ -1,6 +1,7 @@
using System.Security.Claims;
using AyCode.Core.Extensions;
using AyCode.Core.Helpers;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Tests.TestModels;
using AyCode.Models.Server.DynamicMethods;
using AyCode.Services.Server.SignalRs;

View File

@ -7,6 +7,7 @@ using System.Collections;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using AyCode.Core.Serializers.Jsons;
namespace AyCode.Services.Server.SignalRs
{

View File

@ -1,6 +1,7 @@
using AyCode.Core.Extensions;
using AyCode.Core.Helpers;
using AyCode.Core.Loggers;
using AyCode.Core.Serializers.Jsons;
using AyCode.Services.SignalRs;
using Microsoft.AspNetCore.SignalR;

View File

@ -4,6 +4,8 @@ using AyCode.Core;
using AyCode.Core.Extensions;
using AyCode.Core.Helpers;
using AyCode.Core.Loggers;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Jsons;
using AyCode.Models.Server.DynamicMethods;
using AyCode.Services.SignalRs;
using Microsoft.AspNetCore.SignalR;
@ -20,6 +22,12 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
protected AcSerializerOptions SerializerOptions = new AcBinarySerializerOptions();
/// <summary>
/// Enable diagnostic logging for binary serialization debugging.
/// Set to true to log hex dumps of serialized response data.
/// </summary>
public static bool EnableBinaryDiagnostics { get; set; } = false;
#region Connection Lifecycle
public override async Task OnConnectedAsync()
@ -79,6 +87,12 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
Logger.Debug($"[{responseSize / 1024}kb] responseData serialized ({SerializerOptions.SerializerType})");
}
// Log binary diagnostics if enabled
if (EnableBinaryDiagnostics && responseMessage is SignalResponseDataMessage dataMsg && dataMsg.ResponseData != null)
{
LogResponseDataDiagnostics(messageTag, tagName, requestId, dataMsg.ResponseData);
}
await ResponseToCaller(messageTag, responseMessage, requestId);
return;
}
@ -94,6 +108,165 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
await ResponseToCaller(messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Error, null), requestId);
}
/// <summary>
/// Reads a VarUInt from byte array at given position.
/// </summary>
private static (uint value, int bytesRead) ReadVarUIntFromBytes(byte[] data, int startPos)
{
uint value = 0;
int shift = 0;
int bytesRead = 0;
while (startPos + bytesRead < data.Length)
{
var b = data[startPos + bytesRead];
bytesRead++;
value |= (uint)(b & 0x7F) << shift;
if ((b & 0x80) == 0)
break;
shift += 7;
if (shift > 35)
break;
}
return (value, bytesRead);
}
/// <summary>
/// Logs type information about the response data before serialization.
/// </summary>
private void LogResponseDataTypeInfo(object responseData)
{
try
{
var type = responseData.GetType();
Logger.Info($"=== SERVER RESPONSE TYPE INFO (BEFORE SERIALIZE) ===");
Logger.Info($"Runtime Type: {type.Name}");
Logger.Info($"FullName: {type.FullName}");
Logger.Info($"Namespace: {type.Namespace}");
Logger.Info($"Assembly: {type.Assembly.GetName().Name} v{type.Assembly.GetName().Version}");
Logger.Info($"AssemblyQualifiedName: {type.AssemblyQualifiedName}");
Logger.Info($"Assembly Location: {type.Assembly.Location}");
// For collections, log element type info
if (type.IsGenericType)
{
var genericArgs = type.GetGenericArguments();
Logger.Info($"Generic Arguments: [{string.Join(", ", genericArgs.Select(t => t.FullName))}]");
if (genericArgs.Length == 1)
{
var elementType = genericArgs[0];
Logger.Info($"--- ELEMENT TYPE INFO ---");
Logger.Info($"Element Type: {elementType.Name}");
Logger.Info($"Element FullName: {elementType.FullName}");
Logger.Info($"Element Namespace: {elementType.Namespace}");
Logger.Info($"Element Assembly: {elementType.Assembly.GetName().Name} v{elementType.Assembly.GetName().Version}");
Logger.Info($"Element AssemblyQualifiedName: {elementType.AssemblyQualifiedName}");
Logger.Info($"Element Assembly Location: {elementType.Assembly.Location}");
Logger.Info($"Element BaseType: {elementType.BaseType?.FullName ?? "null"}");
// Log inheritance chain
var baseType = elementType.BaseType;
var inheritanceChain = new List<string>();
while (baseType != null && baseType != typeof(object))
{
inheritanceChain.Add($"{baseType.Name} ({baseType.Assembly.GetName().Name})");
baseType = baseType.BaseType;
}
if (inheritanceChain.Count > 0)
{
Logger.Info($"Element Inheritance: {string.Join(" -> ", inheritanceChain)}");
}
LogTypePropertiesServer(elementType, "Element");
}
}
else
{
Logger.Info($"BaseType: {type.BaseType?.FullName ?? "null"}");
LogTypePropertiesServer(type, "Response");
}
}
catch (Exception ex)
{
Logger.Warning($"Failed to log response type info: {ex.Message}");
}
}
/// <summary>
/// Logs all properties of a type with their declaring types.
/// </summary>
private void LogTypePropertiesServer(Type type, string prefix)
{
var props = type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.ToArray();
// Log in declaration order (not alphabetically)
Logger.Info($"{prefix} Property Count: {props.Length}");
for (int i = 0; i < props.Length; i++)
{
var p = props[i];
var declaringType = p.DeclaringType?.Name ?? "?";
var declaringAssembly = p.DeclaringType?.Assembly.GetName().Name ?? "?";
Logger.Info($" {prefix}[{i}]: {p.Name} : {p.PropertyType.Name} (declared in {declaringType} @ {declaringAssembly})");
}
}
/// <summary>
/// Logs diagnostic information about the ResponseData binary for debugging serialization issues.
/// </summary>
private void LogResponseDataDiagnostics(int messageTag, string tagName, int? requestId, byte[] responseData)
{
try
{
Logger.Info($"=== SERVER RESPONSE DATA DIAGNOSTICS (AFTER SERIALIZE) ===");
Logger.Info($"Tag: {messageTag} ({tagName}); RequestId: {requestId}; ResponseData.Length: {responseData.Length}");
Logger.Info($"HEX (first 500 bytes): {Convert.ToHexString(responseData.AsSpan(0, Math.Min(500, responseData.Length)))}");
if (responseData.Length >= 3)
{
var version = responseData[0];
var marker = responseData[1];
Logger.Info($"Version: {version}; Marker: 0x{marker:X2}");
if ((marker & 0x10) != 0)
{
// Read property count as VarUInt
var pos = 2;
var (propCount, bytesRead) = ReadVarUIntFromBytes(responseData, pos);
pos += bytesRead;
Logger.Info($"Header property count: {propCount}");
for (int i = 0; i < (int)propCount && pos < responseData.Length; i++)
{
// Read string length as VarUInt
var (strLen, strLenBytes) = ReadVarUIntFromBytes(responseData, pos);
pos += strLenBytes;
if (pos + (int)strLen <= responseData.Length)
{
var propName = System.Text.Encoding.UTF8.GetString(responseData, pos, (int)strLen);
pos += (int)strLen;
Logger.Info($" Header[{i}]: '{propName}'");
}
else
{
Logger.Info($" Header[{i}]: <truncated at pos {pos}>");
break;
}
}
}
}
}
catch (Exception ex)
{
Logger.Warning($"Failed to log response data diagnostics: {ex.Message}");
}
}
/// <summary>
/// Creates a response message using the configured serializer.
/// </summary>
@ -132,6 +305,12 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
responseData = methodInfoModel.MethodInfo.InvokeMethod(methodsByDeclaringObject.InstanceObject, paramValues);
// Log type information if diagnostics enabled
if (EnableBinaryDiagnostics && responseData != null)
{
LogResponseDataTypeInfo(responseData);
}
if (methodInfoModel.Attribute.SendToOtherClientType != SendToClientType.None)
SendMessageToOthers(methodInfoModel.Attribute.SendToOtherClientTag, responseData).Forget();

View File

@ -1,4 +1,5 @@
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Jsons;
using AyCode.Services.SignalRs;
namespace AyCode.Services.Tests.SignalRs;

View File

@ -3,6 +3,7 @@ using AyCode.Core;
using AyCode.Core.Extensions;
using AyCode.Core.Helpers;
using AyCode.Core.Loggers;
using AyCode.Core.Serializers.Jsons;
using AyCode.Interfaces.Entities;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR.Client;
@ -323,6 +324,9 @@ namespace AyCode.Services.SignalRs
requestModel.ResponseDateTime = DateTime.UtcNow;
Logger.Debug($"[{requestModel.ResponseDateTime.Subtract(requestModel.RequestDateTime).TotalMilliseconds:N0}ms][{messageBytes.Length / 1024}kb]{logText}");
// Diagnostic logging for binary deserialization debugging
LogBinaryDiagnostics(messageTag, messageBytes, requestId);
var responseMessage = SignalRSerializationHelper.DeserializeFromBinary<SignalResponseDataMessage>(messageBytes) ?? new SignalResponseDataMessage();
switch (requestModel.ResponseByRequestId)
@ -358,14 +362,119 @@ namespace AyCode.Services.SignalRs
}
catch (Exception ex)
{
// Enhanced error logging with binary diagnostics
if (messageBytes.Length > 0)
{
LogBinaryDiagnosticsOnError(messageTag, messageBytes, requestId, ex);
}
if (requestId.HasValue && _responseByRequestId.TryRemove(requestId.Value, out var exModel))
SignalRRequestModelPool.Return(exModel);
Logger.Error($"Client OnReceiveMessage; ConnectionState: {GetConnectionState()}; requestId: {requestId}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
Logger.Error($"Client OnReceiveMessage; requestId: {requestId}; ConnectionState: {GetConnectionState()}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
throw;
}
return Task.CompletedTask;
}
/// <summary>
/// Enable diagnostic logging for binary deserialization debugging.
/// Set to true to log hex dumps of received binary data.
/// </summary>
public bool EnableBinaryDiagnostics { get; set; } = false;
/// <summary>
/// Logs binary diagnostics for debugging serialization issues.
/// </summary>
private void LogBinaryDiagnostics(int messageTag, byte[] messageBytes, int? requestId)
{
if (!EnableBinaryDiagnostics || messageBytes.Length == 0) return;
try
{
var hexDump = Convert.ToHexString(messageBytes.AsSpan(0, Math.Min(500, messageBytes.Length)));
Logger.Info($"=== BINARY DIAGNOSTICS === Tag: {messageTag}; RequestId: {requestId}; Length: {messageBytes.Length}");
Logger.Info($"HEX (first 500 bytes): {hexDump}");
// Parse header info
if (messageBytes.Length >= 3)
{
var version = messageBytes[0];
var marker = messageBytes[1];
Logger.Info($"Version: {version}; Marker: 0x{marker:X2}");
if ((marker & 0x10) != 0 && messageBytes.Length > 2)
{
var propCount = messageBytes[2];
Logger.Info($"Header property count: {propCount}");
// Parse first 10 property names
var pos = 3;
for (int i = 0; i < Math.Min((int)propCount, 10) && pos < messageBytes.Length; i++)
{
var strLen = messageBytes[pos++];
if (pos + strLen <= messageBytes.Length)
{
var propName = System.Text.Encoding.UTF8.GetString(messageBytes, pos, strLen);
pos += strLen;
Logger.Info($" [{i}]: '{propName}'");
}
}
}
}
}
catch (Exception ex)
{
Logger.Warning($"Failed to log binary diagnostics: {ex.Message}");
}
}
/// <summary>
/// Logs binary diagnostics when an error occurs during deserialization.
/// </summary>
private void LogBinaryDiagnosticsOnError(int messageTag, byte[] messageBytes, int? requestId, Exception error)
{
try
{
Logger.Error($"=== BINARY DESERIALIZATION ERROR ===");
Logger.Error($"Tag: {messageTag}; RequestId: {requestId}; Length: {messageBytes.Length}");
Logger.Error($"Error: {error.Message}");
var hexDump = Convert.ToHexString(messageBytes.AsSpan(0, Math.Min(1000, messageBytes.Length)));
Logger.Error($"HEX (first 1000 bytes): {hexDump}");
// Parse header info
if (messageBytes.Length >= 3)
{
var version = messageBytes[0];
var marker = messageBytes[1];
Logger.Error($"Version: {version}; Marker: 0x{marker:X2}");
if ((marker & 0x10) != 0 && messageBytes.Length > 2)
{
var propCount = messageBytes[2];
Logger.Error($"Header property count: {propCount}");
// Parse ALL property names
var pos = 3;
for (int i = 0; i < propCount && pos < messageBytes.Length; i++)
{
var strLen = messageBytes[pos++];
if (pos + strLen <= messageBytes.Length)
{
var propName = System.Text.Encoding.UTF8.GetString(messageBytes, pos, strLen);
pos += strLen;
Logger.Error($" Header[{i}]: '{propName}'");
}
}
}
}
}
catch (Exception ex)
{
Logger.Warning($"Failed to log binary diagnostics on error: {ex.Message}");
}
}
}
}

View File

@ -2,6 +2,7 @@
using AyCode.Core.Interfaces;
using System.Buffers;
using System.Runtime.CompilerServices;
using AyCode.Core.Serializers.Jsons;
using JsonIgnoreAttribute = Newtonsoft.Json.JsonIgnoreAttribute;
using STJIgnore = System.Text.Json.Serialization.JsonIgnoreAttribute;
@ -149,6 +150,13 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa
[JsonIgnore] [STJIgnore] private byte[]? _rentedDecompressedBuffer;
[JsonIgnore] [STJIgnore] private int _decompressedLength;
/// <summary>
/// Enable diagnostic logging for ResponseData deserialization.
/// When set, logs hex dump and header info before deserialization.
/// </summary>
[JsonIgnore] [STJIgnore]
public static Action<string>? DiagnosticLogger { get; set; }
public SignalResponseDataMessage() { }
public SignalResponseDataMessage(int messageTag, SignalResponseStatus status)
@ -175,7 +183,21 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa
if (ResponseData == null) return default;
if (DataSerializerType == AcSerializerType.Binary)
return (T)(_cachedResponseData = ResponseData.BinaryTo<T>()!);
{
try
{
// Log diagnostics if enabled
LogResponseDataDiagnostics<T>();
return (T)(_cachedResponseData = ResponseData.BinaryTo<T>()!);
}
catch (Exception ex)
{
// Log detailed error diagnostics
LogResponseDataError<T>(ex);
throw;
}
}
// Decompress Brotli to pooled buffer and deserialize directly
EnsureDecompressed();
@ -184,6 +206,207 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa
return result;
}
private void LogResponseDataDiagnostics<T>()
{
if (DiagnosticLogger == null || ResponseData == null) return;
try
{
var targetType = typeof(T);
DiagnosticLogger($"=== RESPONSE DATA DIAGNOSTICS (DESERIALIZE) ===");
DiagnosticLogger($"Target Type: {targetType.Name}");
DiagnosticLogger($"Target FullName: {targetType.FullName}");
DiagnosticLogger($"Target Namespace: {targetType.Namespace}");
DiagnosticLogger($"Target Assembly: {targetType.Assembly.GetName().Name} v{targetType.Assembly.GetName().Version}");
DiagnosticLogger($"Target AssemblyQualifiedName: {targetType.AssemblyQualifiedName}");
DiagnosticLogger($"Target Assembly Location: {targetType.Assembly.Location}");
// Log element type for collections
if (targetType.IsGenericType)
{
var genericArgs = targetType.GetGenericArguments();
DiagnosticLogger($"Generic Arguments: [{string.Join(", ", genericArgs.Select(t => t.FullName))}]");
if (genericArgs.Length == 1)
{
var elementType = genericArgs[0];
DiagnosticLogger($"--- ELEMENT TYPE INFO ---");
DiagnosticLogger($"Element Type: {elementType.Name}");
DiagnosticLogger($"Element FullName: {elementType.FullName}");
DiagnosticLogger($"Element Namespace: {elementType.Namespace}");
DiagnosticLogger($"Element Assembly: {elementType.Assembly.GetName().Name} v{elementType.Assembly.GetName().Version}");
DiagnosticLogger($"Element AssemblyQualifiedName: {elementType.AssemblyQualifiedName}");
DiagnosticLogger($"Element Assembly Location: {elementType.Assembly.Location}");
DiagnosticLogger($"Element BaseType: {elementType.BaseType?.FullName ?? "null"}");
// Log inheritance chain
var baseType = elementType.BaseType;
var inheritanceChain = new List<string>();
while (baseType != null && baseType != typeof(object))
{
inheritanceChain.Add($"{baseType.Name} ({baseType.Assembly.GetName().Name})");
baseType = baseType.BaseType;
}
if (inheritanceChain.Count > 0)
{
DiagnosticLogger($"Element Inheritance: {string.Join(" -> ", inheritanceChain)}");
}
LogTypeProperties(elementType, "Element");
}
}
else
{
DiagnosticLogger($"BaseType: {targetType.BaseType?.FullName ?? "null"}");
LogTypeProperties(targetType, "Target");
}
DiagnosticLogger($"ResponseData.Length: {ResponseData.Length}");
DiagnosticLogger($"HEX (first 500 bytes): {Convert.ToHexString(ResponseData.AsSpan(0, Math.Min(500, ResponseData.Length)))}");
// Parse header with VarInt support
LogBinaryHeader(ResponseData);
}
catch (Exception ex)
{
DiagnosticLogger($"Failed to log diagnostics: {ex.Message}");
}
}
private void LogTypeProperties(Type type, string prefix)
{
if (DiagnosticLogger == null) return;
var props = type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.ToArray();
// Log in declaration order (not alphabetically) to match serialization order
DiagnosticLogger($"{prefix} Property Count: {props.Length}");
for (int i = 0; i < props.Length; i++)
{
var p = props[i];
var declaringType = p.DeclaringType?.Name ?? "?";
var declaringAssembly = p.DeclaringType?.Assembly.GetName().Name ?? "?";
DiagnosticLogger($" {prefix}[{i}]: {p.Name} : {p.PropertyType.Name} (declared in {declaringType} @ {declaringAssembly})");
}
}
private void LogBinaryHeader(byte[] data)
{
if (DiagnosticLogger == null || data.Length < 3) return;
var version = data[0];
var marker = data[1];
DiagnosticLogger($"Binary Version: {version}; Marker: 0x{marker:X2}");
// Check if metadata flag is set
if ((marker & 0x10) == 0)
{
DiagnosticLogger("Header: No metadata (property names inline)");
return;
}
// Read property count as VarUInt
var pos = 2;
var (propCount, bytesRead) = ReadVarUIntFromSpan(data.AsSpan(pos));
pos += bytesRead;
DiagnosticLogger($"Header Property Count: {propCount}");
for (int i = 0; i < (int)propCount && pos < data.Length; i++)
{
// Read string length as VarUInt
var (strLen, strLenBytes) = ReadVarUIntFromSpan(data.AsSpan(pos));
pos += strLenBytes;
if (pos + (int)strLen <= data.Length)
{
var propName = System.Text.Encoding.UTF8.GetString(data, pos, (int)strLen);
pos += (int)strLen;
DiagnosticLogger($" Header[{i}]: '{propName}'");
}
else
{
DiagnosticLogger($" Header[{i}]: <truncated at pos {pos}>");
break;
}
}
}
private static (uint value, int bytesRead) ReadVarUIntFromSpan(ReadOnlySpan<byte> span)
{
uint value = 0;
int shift = 0;
int bytesRead = 0;
while (bytesRead < span.Length)
{
var b = span[bytesRead++];
value |= (uint)(b & 0x7F) << shift;
if ((b & 0x80) == 0)
break;
shift += 7;
if (shift > 35)
break;
}
return (value, bytesRead);
}
private void LogResponseDataError<T>(Exception error)
{
if (DiagnosticLogger == null || ResponseData == null) return;
try
{
var targetType = typeof(T);
DiagnosticLogger($"=== RESPONSE DATA DESERIALIZATION ERROR ===");
DiagnosticLogger($"Error: {error.Message}");
DiagnosticLogger($"Target Type: {targetType.Name}");
DiagnosticLogger($"Target FullName: {targetType.FullName}");
DiagnosticLogger($"Target Namespace: {targetType.Namespace}");
DiagnosticLogger($"Target Assembly: {targetType.Assembly.GetName().Name} v{targetType.Assembly.GetName().Version}");
DiagnosticLogger($"Target AssemblyQualifiedName: {targetType.AssemblyQualifiedName}");
// Log element type for collections
if (targetType.IsGenericType)
{
var genericArgs = targetType.GetGenericArguments();
DiagnosticLogger($"Generic Arguments: [{string.Join(", ", genericArgs.Select(t => t.FullName))}]");
if (genericArgs.Length == 1)
{
var elementType = genericArgs[0];
DiagnosticLogger($"Element Type: {elementType.FullName}");
DiagnosticLogger($"Element Assembly: {elementType.Assembly.GetName().Name}");
LogTypeProperties(elementType, "Element");
}
}
else
{
LogTypeProperties(targetType, "Target");
}
DiagnosticLogger($"ResponseData.Length: {ResponseData.Length}");
DiagnosticLogger($"HEX (first 1000 bytes): {Convert.ToHexString(ResponseData.AsSpan(0, Math.Min(1000, ResponseData.Length)))}");
// Parse header
LogBinaryHeader(ResponseData);
// Log inner exception if present
if (error.InnerException != null)
{
DiagnosticLogger($"Inner Exception: {error.InnerException.Message}");
}
// Log stack trace
DiagnosticLogger($"Stack Trace: {error.StackTrace}");
}
catch (Exception ex)
{
DiagnosticLogger?.Invoke($"Failed to log error diagnostics: {ex.Message}");
}
}
/// <summary>
/// Gets the decompressed JSON bytes as a ReadOnlySpan for direct processing.
/// </summary>

View File

@ -2,6 +2,8 @@ using System.Buffers;
using System.Runtime.CompilerServices;
using AyCode.Core.Compression;
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Jsons;
namespace AyCode.Services.SignalRs;
@ -104,7 +106,7 @@ public static class SignalRSerializationHelper
public static byte[] SerializeToCompressedJson<T>(T value, AcJsonSerializerOptions? options = null)
{
var json = value.ToJson(options ?? AcJsonSerializerOptions.Default);
return BrotliHelper.Compress(json);
return GzipHelper.Compress(json);
}
/// <summary>
@ -113,7 +115,7 @@ public static class SignalRSerializationHelper
/// </summary>
public static T? DeserializeFromCompressedJson<T>(byte[] compressedData)
{
var (buffer, length) = BrotliHelper.DecompressToRentedBuffer(compressedData.AsSpan());
var (buffer, length) = GzipHelper.DecompressToRentedBuffer(compressedData.AsSpan());
try
{
return AcJsonDeserializer.Deserialize<T>(new ReadOnlySpan<byte>(buffer, 0, length));
@ -131,7 +133,7 @@ public static class SignalRSerializationHelper
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static (byte[] Buffer, int Length) DecompressToRentedBuffer(byte[] compressedData)
{
return BrotliHelper.DecompressToRentedBuffer(compressedData.AsSpan());
return GzipHelper.DecompressToRentedBuffer(compressedData.AsSpan());
}
#endregion
@ -179,7 +181,7 @@ public static class SignalRSerializationHelper
json = responseData.ToJson(jsonOptions);
}
return BrotliHelper.Compress(json);
return GzipHelper.Compress(json);
}
#endregion

View File

@ -0,0 +1,7 @@
@echo off
setlocal
set ROOT=h:\Applications\Aycode\Source\AyCode.Core
set COUNT=5
powershell -ExecutionPolicy Bypass -File "%ROOT%\MergeBenchmarksHtmlDropdown.ps1" -maxCount %COUNT%
endlocal
pause

View File

@ -0,0 +1,114 @@
param(
[int]$maxCount = 5
)
$root = "h:\Applications\Aycode\Source\AyCode.Core"
$output = Join-Path $root "AllBenchmarksDropdown.html"
if (Test-Path $output) { Remove-Item $output }
$htmlFiles = Get-ChildItem -Path $root -Recurse -Filter "*-report.html" | Sort-Object LastWriteTime -Descending | Select-Object -First $maxCount
@"
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<title>BenchmarkDotNet Riportok (Dropdown)</title>
<style>
body { font-family: Segoe UI, Arial, sans-serif; margin: 2em; background: #f8f9fa; }
select { font-size: 1.1em; padding: 0.2em; }
.report-content { background: #fff; border: 1px solid #ccc; padding: 1em; margin-top: 1em; border-radius: 6px; box-shadow: 0 2px 8px #0001; min-height: 200px; }
h1 { font-size: 1.5em; }
.filename { color: #888; font-size: 0.95em; }
.compare { color: #007700; font-size: 1.1em; margin-bottom: 1em; }
.compare.negative { color: #bb2222; }
iframe { width: 100%; min-height: 600px; border: none; background: #fff; }
</style>
</head>
<body>
<h1>BenchmarkDotNet Riportok</h1>
<label for='reportSelect'>Válassz riportot:</label>
<select id='reportSelect'>
"@ | Set-Content -Encoding UTF8 -Path $output
$reports = @()
foreach ($file in $htmlFiles) {
$id = ([System.IO.Path]::GetFileNameWithoutExtension($file.Name) + "_" + [Math]::Abs($file.FullName.GetHashCode()))
$runName = ($file.FullName -replace "^.*SwitcherRun_([\w\dT]+).*", 'SwitcherRun_$1')
if ($runName -eq $file.FullName) { $runName = $file.BaseName }
$relPath = $file.FullName.Substring($root.Length + 1).Replace("\", "/")
$reports += @{ Id = $id; Title = $runName; Path = $relPath }
Add-Content -Encoding UTF8 -Path $output -Value " <option value='$id'>$runName</option>"
}
@"
</select>
<div class='filename' id='filename'></div>
<div class='compare' id='compare'></div>
<div class='report-content'><iframe id='reportFrame'></iframe></div>
<script>
// Relatív riport fájlok
const reports = {
"@ | Add-Content -Encoding UTF8 -Path $output
$means = @{}
foreach ($r in $reports) {
Add-Content -Encoding UTF8 -Path $output -Value " '$($r.Id)': '$($r.Path)',"
$html = Get-Content (Join-Path $root $r.Path) -Raw
$mean = [regex]::Match($html, '(?<=<td>Mean</td><td>)[0-9.,]+').Value
$means[$r.Id] = $mean
}
@"
};
const means = {
"@ | Add-Content -Encoding UTF8 -Path $output
foreach ($r in $reports) {
$mean = $means[$r.Id]
Add-Content -Encoding UTF8 -Path $output -Value " '$($r.Id)': '$mean',"
}
@"
};
const select = document.getElementById('reportSelect');
const frame = document.getElementById('reportFrame');
const filename = document.getElementById('filename');
const compare = document.getElementById('compare');
function showReport() {
const id = select.value;
if (reports[id]) {
frame.src = reports[id];
} else {
frame.srcdoc = '<i>Nincs tartalom.</i>';
}
const opt = select.options[select.selectedIndex];
filename.textContent = opt ? opt.text : '';
// Összehasonlítás az eggyel korábbival
const idx = select.selectedIndex;
if (idx < select.options.length - 1) {
const prevId = select.options[idx + 1].value;
const currMean = parseFloat(means[id].replace(',','.'));
const prevMean = parseFloat(means[prevId].replace(',','.'));
if (!isNaN(currMean) && !isNaN(prevMean) && prevMean > 0) {
const diff = currMean - prevMean;
const percent = (diff / prevMean * 100).toFixed(2);
const sign = percent > 0 ? '+' : '';
compare.textContent = `Eltérés az elõzõhöz képest: ${sign}${percent}% (${currMean} vs ${prevMean})`;
compare.className = 'compare' + (percent > 0 ? ' negative' : '');
} else {
compare.textContent = '';
}
} else {
compare.textContent = '';
}
}
select.addEventListener('change', showReport);
window.onload = showReport;
</script>
</body>
</html>
"@ | Add-Content -Encoding UTF8 -Path $output
Write-Host "Összefûzve: $output"

6
RunFullBenchmark.bat Normal file
View File

@ -0,0 +1,6 @@
dotnet run -c Release --project AyCode.Benchmark
echo.
pause
endlocal
exit /b %EXITCODE%

17
RunQuickBenchmark.bat Normal file
View File

@ -0,0 +1,17 @@
@echo off
REM Run Quick Benchmark PowerShell script using pwsh if available, otherwise Windows PowerShell
setlocal enabledelayedexpansion
set SCRIPT_DIR=%~dp0
where pwsh >nul 2>&1
if %ERRORLEVEL%==0 (
pwsh -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%RunQuickBenchmark.ps1" -All
set EXITCODE=!ERRORLEVEL!
) else (
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%RunQuickBenchmark.ps1" -All
set EXITCODE=!ERRORLEVEL!
)
echo.
pause
endlocal
exit /b %EXITCODE%

166
RunQuickBenchmark.ps1 Normal file
View File

@ -0,0 +1,166 @@
# AcBinary Quick Benchmark Runner
# Run this script to execute all binary serialization benchmarks
# Usage: .\RunQuickBenchmark.ps1 [-All] [-Full] [-WithRef] [-Populate] [-StringIntern] [-MessagePack]
param(
[switch]$All,
[switch]$Full,
[switch]$WithRef,
[switch]$Populate,
[switch]$StringIntern,
[switch]$MessagePack,
[switch]$Help
)
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# Colors for output
function Write-ColorOutput($ForegroundColor, $Message) {
$fc = $host.UI.RawUI.ForegroundColor
$host.UI.RawUI.ForegroundColor = $ForegroundColor
Write-Output $Message
$host.UI.RawUI.ForegroundColor = $fc
}
function Show-Help {
Write-Host ""
Write-Host "????????????????????????????????????????????????????????????????????????????????" -ForegroundColor Cyan
Write-Host "? AcBinary Quick Benchmark Runner ?" -ForegroundColor Cyan
Write-Host "????????????????????????????????????????????????????????????????????????????????" -ForegroundColor Cyan
Write-Host ""
Write-Host "Usage: .\RunQuickBenchmark.ps1 [options]" -ForegroundColor Yellow
Write-Host ""
Write-Host "Options:" -ForegroundColor Green
Write-Host " -All Run all benchmark tests"
Write-Host " -Full Run full AcBinary vs MessagePack comparison"
Write-Host " -WithRef Run WithRef vs NoRef comparison"
Write-Host " -Populate Run Populate & Merge benchmarks"
Write-Host " -StringIntern Run String Interning benchmarks"
Write-Host " -MessagePack Run MessagePack comparison"
Write-Host " -Help Show this help message"
Write-Host ""
Write-Host "Examples:" -ForegroundColor Green
Write-Host " .\RunQuickBenchmark.ps1 -All # Run all tests"
Write-Host " .\RunQuickBenchmark.ps1 -Full # Full benchmark comparison"
Write-Host " .\RunQuickBenchmark.ps1 -WithRef -Populate # Multiple specific tests"
Write-Host ""
}
function Run-DotNetTest {
param(
[string]$Filter,
[string]$Description
)
Write-Host ""
Write-Host "Running: $Description" -ForegroundColor Yellow
Write-Host ("=" * 80) -ForegroundColor DarkGray
$testProject = Join-Path $ScriptDir "AyCode.Core.Tests\AyCode.Core.Tests.csproj"
if (-not (Test-Path $testProject)) {
Write-Host "Error: Test project not found at $testProject" -ForegroundColor Red
return $false
}
$result = dotnet test $testProject `
--filter "FullyQualifiedName~$Filter" `
--configuration Release `
--logger "console;verbosity=detailed" `
--no-build 2>&1
$result | ForEach-Object { Write-Host $_ }
return $LASTEXITCODE -eq 0
}
# Check for help
if ($Help -or ($PSBoundParameters.Count -eq 0)) {
Show-Help
if ($PSBoundParameters.Count -eq 0) {
Write-Host "No options specified. Running full benchmark..." -ForegroundColor Yellow
$Full = $true
} else {
exit 0
}
}
Write-Host ""
Write-Host "????????????????????????????????????????????????????????????????????????????????" -ForegroundColor Cyan
Write-Host "? AcBinary Quick Benchmark ?" -ForegroundColor Cyan
Write-Host "????????????????????????????????????????????????????????????????????????????????" -ForegroundColor Cyan
Write-Host ""
Write-Host "Building solution in Release mode..." -ForegroundColor Yellow
# Build first
$buildResult = dotnet build (Join-Path $ScriptDir "AyCode.Core.sln") --configuration Release --verbosity minimal 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "Build failed!" -ForegroundColor Red
$buildResult | ForEach-Object { Write-Host $_ }
exit 1
}
Write-Host "Build successful!" -ForegroundColor Green
$testsRun = 0
$testsPassed = 0
# Run requested tests
if ($All) {
$Full = $true
$WithRef = $true
$Populate = $true
$StringIntern = $true
$MessagePack = $true
}
if ($Full) {
$testsRun++
if (Run-DotNetTest "QuickBenchmark.RunFullBenchmarkComparison" "Full AcBinary vs MessagePack Comparison") {
$testsPassed++
}
}
if ($WithRef) {
$testsRun++
if (Run-DotNetTest "QuickBenchmark.RunWithRefVsNoRefComparison" "WithRef vs NoRef Comparison") {
$testsPassed++
}
}
if ($Populate) {
$testsRun++
if (Run-DotNetTest "QuickBenchmark.RunPopulateAndMergeBenchmark" "Populate & Merge Benchmark") {
$testsPassed++
}
}
if ($StringIntern) {
$testsRun++
if (Run-DotNetTest "QuickBenchmark.RunStringInterningBenchmark" "String Interning Benchmark") {
$testsPassed++
}
$testsRun++
if (Run-DotNetTest "QuickBenchmark.RunStringInterningVsMessagePack" "String Interning vs MessagePack") {
$testsPassed++
}
}
if ($MessagePack) {
$testsRun++
if (Run-DotNetTest "QuickBenchmark.RunMessagePackComparison" "MessagePack Comparison") {
$testsPassed++
}
}
# Summary
Write-Host ""
Write-Host ("=" * 80) -ForegroundColor DarkGray
Write-Host ""
if ($testsPassed -eq $testsRun) {
Write-Host "All $testsRun benchmark(s) completed successfully!" -ForegroundColor Green
} else {
Write-Host "$testsPassed of $testsRun benchmark(s) completed." -ForegroundColor Yellow
}
Write-Host ""