Compare commits
8 Commits
5601c0d3e2
...
4b2d3f4e75
| Author | SHA1 | Date |
|---|---|---|
|
|
4b2d3f4e75 | |
|
|
cde2b5e529 | |
|
|
762088caf7 | |
|
|
b8143e4897 | |
|
|
a832d8e86d | |
|
|
bc30a3aede | |
|
|
b17c2df6c2 | |
|
|
271f23d0f6 |
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Serializers.Jsons;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using MessagePack;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -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>()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<StockTakingItem> : 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TItem> 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<StockTakingItem> 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)}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<TStockTakingItem>.
|
||||
/// </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<StockTakingItem>.
|
||||
/// </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
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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<T> reference handling.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace AyCode.Core.Extensions;
|
||||
namespace AyCode.Core.Serializers.Jsons;
|
||||
|
||||
public enum AcSerializerType : byte
|
||||
{
|
||||
|
|
@ -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 { }
|
||||
|
|
@ -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<CustomerDto>.
|
||||
/// </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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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) { }
|
||||
}
|
||||
|
|
@ -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) { }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Serializers.Jsons;
|
||||
using AyCode.Services.SignalRs;
|
||||
|
||||
namespace AyCode.Services.Tests.SignalRs;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
dotnet run -c Release --project AyCode.Benchmark
|
||||
|
||||
echo.
|
||||
pause
|
||||
endlocal
|
||||
exit /b %EXITCODE%
|
||||
|
|
@ -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%
|
||||
|
|
@ -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 ""
|
||||
Loading…
Reference in New Issue