using System; using System.Collections.Generic; using System.Linq; using FluentAssertions; using GameCult.Caching.MessagePack; using CultMath; using MessagePack; using MessagePack.Formatters; using NUnit.Framework; [assembly: CultCacheFormatterResolver(typeof(GameCult.Caching.Tests.CultMathSerializationTests.Float2Override))] namespace GameCult.Caching.Tests { public sealed class CultMathSerializationTests { private static readonly MessagePackSerializerOptions Options = CultDocumentMessagePackSerialization.Options; private static string Hex(T value) => Convert.ToHexString(MessagePackSerializer.Serialize(value, Options)); private static T Decode(string hex) => MessagePackSerializer.Deserialize(Convert.FromHexString(hex), Options); [MessagePackObject] public sealed class Carrier { [Key(0)] public float2 Position; [Key(1)] public float3? Velocity; [Key(2)] public float2[] Path = Array.Empty(); [Key(3)] public List Cells = new(); } // Bytes the pre-collapse CultVec2/CultVec3/CultRect encodings and Aetheria's Float2/Int2/Bool2/Float3/Float4 // formatters produce: an array of components in declaration order, float32 as 0xCA. [Test] public void Float2_MatchesTheLegacyVectorBytes() { const string hex = "92CA3F800000CA40000000"; Hex(new float2(1f, 2f)).Should().Be(hex); Decode(hex).Should().Be(new float2(1f, 2f)); } [Test] public void Float3_MatchesTheLegacyVectorBytes() { const string hex = "93CA3F800000CA40000000CA40400000"; Hex(new float3(1f, 2f, 3f)).Should().Be(hex); Decode(hex).Should().Be(new float3(1f, 2f, 3f)); } [Test] public void Rect_MatchesTheLegacyCultRectBytes() { const string hex = "9292CABF800000CAC000000092CA40A00000CA40E00000"; Hex(new rect(5f, 7f, -1f, -2f)).Should().Be(hex); Decode(hex).Should().Be(new rect(-1f, -2f, 5f, 7f)); } [Test] public void ConsumerFormatterShapes_MatchByteForByte() { Hex(new float4(1f, 2f, 3f, 4f)).Should().Be("94CA3F800000CA40000000CA40400000CA40800000"); Hex(new int2(3, -4)).Should().Be("9203FC"); Hex(new bool2(true, false)).Should().Be("92C3C2"); } [Test] public void Decode_SkipsExtraElements_AndLeavesMissingOnesZero() { Decode("93CA3F800000CA40000000CA40400000").Should().Be(new float2(1f, 2f)); Decode("92CA3F800000CA40000000").Should().Be(new float3(1f, 2f, 0f)); Decode("9392CABF800000CAC000000092CA40A00000CA40E0000001").Should().Be(new rect(-1f, -2f, 5f, 7f)); } [Test] public void Decode_RefusesNilAndNonArrays() { Action nil = () => Decode("C0"); nil.Should().Throw(); Action notArray = () => Decode("01"); notArray.Should().Throw(); } [Test] public void EveryFormattedType_RoundTrips() { RoundTrip(new float2(1.5f, -2f)); RoundTrip(new float3(1f, 2f, 3f)); RoundTrip(new float4(1f, 2f, 3f, 4f)); RoundTrip(new double2(1e300, -2.5)); RoundTrip(new double3(1, 2, 3)); RoundTrip(new int2(-7, 900000)); RoundTrip(new int3(1, -2, 3)); RoundTrip(new int4(1, 2, 3, int.MinValue)); RoundTrip(new bool2(true, false)); RoundTrip(new bool3(false, true, true)); RoundTrip(new bool4(true, false, false, true)); RoundTrip(new quaternion(0.1f, 0.2f, 0.3f, 0.9f)); RoundTrip(new Color32(255, 128, 0, 7)); RoundTrip(new CultMath.Random(12345u)); RoundTrip(new float2x2(1f, 2f, 3f, 4f)); RoundTrip(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)); RoundTrip(new rect(-1f, -2f, 5f, 7f)); RoundTrip(new CultCellular(new float4(1, 2, 3, 4), new float4(5, 6, 7, 8), 0.25f)); RoundTrip(new CultPhasor(new float4(1, 2, 3, 4), new float4(5, 6, 7, 8))); RoundTrip(new SpanNoise(new float4(1, 2, 3, 4), 0.25f, 0.5f)); RoundTrip(new SpanPath { midpoint = new float3(1f, 2f, 3f), velocity = new float3(4f, 5f, 6f), speed = 7f, acceleration = 8f, halfLength = 9f }); RoundTrip(new SpanStage { frequency = 1f, stretch = 2f, axis = new float3(3f, 4f, 5f), shift = new float3(6f, 7f, 8f), offset = new float3(9f, 10f, 11f), octaveStep = new float3(12f, 13f, 14f) }); RoundTrip(new CultSiteCells(new float4(1, 2, 3, 4), new float4(5, 6, 7, 8), 9f, 10f)); RoundTrip(new CultSiteCells(new float4(0, 0, 0, 1.0e30f), new float4(0, 0, 0, 1.0e30f), -1f, -1f)); } [Test] public void Matrices_AreArraysOfRows() { Hex(new float2x2(1f, 2f, 3f, 4f)).Should().Be("9292CA3F800000CA4000000092CA40400000CA40800000"); } [Test] public void EveryShape_IsPinnedByteForByte() { Hex(new double2(1d, 2d)).Should().Be("92CB3FF0000000000000CB4000000000000000"); Hex(new double3(1d, 2d, 3d)).Should().Be("93CB3FF0000000000000CB4000000000000000CB4008000000000000"); Hex(new int3(1, -2, 3)).Should().Be("9301FE03"); Hex(new int4(1, 2, 3, -1)).Should().Be("94010203FF"); Hex(new bool3(true, false, true)).Should().Be("93C3C2C3"); Hex(new bool4(false, true, true, false)).Should().Be("94C2C3C3C2"); Hex(new quaternion(1f, 2f, 3f, 4f)).Should().Be("94CA3F800000CA40000000CA40400000CA40800000"); Hex(new Color32(255, 128, 0, 7)).Should().Be("94CCFFCC800007"); Hex(new CultMath.Random(12345u)).Should().Be("91CD3039"); Hex(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)).Should().Be( "93" + "93CA3F800000CA40000000CA40400000" + "93CA40800000CA40A00000CA40C00000" + "93CA40E00000CA41000000CA41100000"); Hex(new CultCellular(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f), 0.25f)).Should().Be( "93" + "94CA3F800000CA40000000CA40400000CA40800000" + "94CA40A00000CA40C00000CA40E00000CA41000000" + "CA3E800000"); Hex(new CultPhasor(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f))).Should().Be( "92" + "94CA3F800000CA40000000CA40400000CA40800000" + "94CA40A00000CA40C00000CA40E00000CA41000000"); Hex(new SpanNoise(new float4(1f, 2f, 3f, 4f), 0.25f, 0.5f)).Should().Be( "93" + "94CA3F800000CA40000000CA40400000CA40800000" + "CA3E800000" + "CA3F000000"); Hex(new SpanPath { midpoint = new float3(1f, 2f, 3f), velocity = new float3(4f, 5f, 6f), speed = 7f, acceleration = 8f, halfLength = 9f }).Should().Be( "95" + "93CA3F800000CA40000000CA40400000" + "93CA40800000CA40A00000CA40C00000" + "CA40E00000CA41000000CA41100000"); Hex(new SpanStage { frequency = 1f, stretch = 2f, axis = new float3(3f, 4f, 5f), shift = new float3(6f, 7f, 8f), offset = new float3(9f, 10f, 11f), octaveStep = new float3(12f, 13f, 14f) }).Should().Be( "96" + "CA3F800000" + "CA40000000" + "93CA40400000CA40800000CA40A00000" + "93CA40C00000CA40E00000CA41000000" + "93CA41100000CA41200000CA41300000" + "93CA41400000CA41500000CA41600000"); Hex(new CultSiteCells(new float4(1, 2, 3, 4), new float4(5, 6, 7, 8), 9f, 10f)).Should().Be( "94" + "94CA3F800000CA40000000CA40400000CA40800000" + "94CA40A00000CA40C00000CA40E00000CA41000000" + "CA41100000" + "CA41200000"); } [Test] public void Color32_MissingAlphaDecodesAsOpaque() { Decode("93CCFFCC8000").Should().Be(new Color32(255, 128, 0, 255)); Decode("94CCFFCC800007").Should().Be(new Color32(255, 128, 0, 7)); } [Test] public void Random_StateZero_IsTheGeneratorDefaultSeed() { // CultMath's generator maps state 0 to its default seed, so a zero or missing state is not degenerate. var reference = new CultMath.Random(0u); var expected = new[] { reference.NextUInt(), reference.NextUInt(), reference.NextUInt() }; foreach (var hex in new[] { "9100", "90" }) { var decoded = Decode(hex); new[] { decoded.NextUInt(), decoded.NextUInt(), decoded.NextUInt() }.Should().Equal(expected); } } [Test] public void ConsumerResolvers_WinOverCultMathResolver() { var overridden = CultDocumentMessagePackSerialization.OptionsFor(typeof(Float2Override).Assembly); MessagePackSerializer.Serialize(new float2(1f, 2f), overridden).Should().Equal(0xC3); Hex(new float2(1f, 2f)).Should().Be("92CA3F800000CA40000000"); } public sealed class Float2Override : IFormatterResolver { public static readonly Float2Override Instance = new(); public IMessagePackFormatter? GetFormatter() => typeof(T) == typeof(float2) ? (IMessagePackFormatter)(object)Formatter.Instance : null; private sealed class Formatter : IMessagePackFormatter { public static readonly Formatter Instance = new(); public void Serialize(ref MessagePackWriter writer, float2 value, MessagePackSerializerOptions options) => writer.Write(true); public float2 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) => throw new NotSupportedException(); } } [Test] public void EveryPublicCultMathValueType_HasAFormatter() { var valueTypes = typeof(float2).Assembly.GetExportedTypes() .Where(t => t.IsValueType && !t.IsEnum) .ToHashSet(); valueTypes.Except(CultMathResolver.FormattedTypes).Should().BeEmpty( "every public CultMath value type serializes through CultMathResolver"); CultMathResolver.FormattedTypes.Except(valueTypes).Should().BeEmpty(); } [Test] public void TheDefaultChain_ComposesMathIntoNullablesCollectionsAndDocuments() { var value = new Carrier { Position = new float2(1f, 2f), Velocity = new float3(3f, 4f, 5f), Path = new[] { new float2(0f, 1f), new float2(2f, 3f) }, Cells = new List { new int2(1, 2) }, }; var decoded = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(value, Options), Options); decoded.Position.Should().Be(value.Position); decoded.Velocity.Should().Be(value.Velocity); decoded.Path.Should().Equal(value.Path); decoded.Cells.Should().Equal(value.Cells); MessagePackSerializer.Serialize(new Carrier(), Options).Should().NotBeEmpty(); var absent = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(new Carrier(), Options), Options); absent.Velocity.Should().BeNull(); } private static void RoundTrip(T value) { var bytes = MessagePackSerializer.Serialize(value, Options); MessagePackSerializer.Deserialize(bytes, Options).Should().Be(value); } } }