512 lines
28 KiB
C#
512 lines
28 KiB
C#
using System;
|
||
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using System.Globalization;
|
||
using System.Linq;
|
||
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
using CultMath;
|
||
using FluentAssertions;
|
||
using GameCult.Caching.MessagePack;
|
||
using NUnit.Framework;
|
||
|
||
namespace GameCult.Caching.Tests
|
||
{
|
||
public sealed class CultMathJsonTests
|
||
{
|
||
private static readonly JsonSerializerOptions Options = new JsonSerializerOptions().AddCultMathConverters();
|
||
|
||
private static string Json<T>(T value) => JsonSerializer.Serialize(value, Options);
|
||
|
||
private static T Parse<T>(string json) => JsonSerializer.Deserialize<T>(json, Options)!;
|
||
|
||
[Test]
|
||
public void EveryShape_IsPinned()
|
||
{
|
||
Json(new float2(1f, 2f)).Should().Be("""{"x":1,"y":2}""");
|
||
Json(new float3(1f, 2f, 3f)).Should().Be("""{"x":1,"y":2,"z":3}""");
|
||
Json(new float4(1f, 2f, 3f, 4f)).Should().Be("""{"x":1,"y":2,"z":3,"w":4}""");
|
||
Json(new double2(1d, 2.5d)).Should().Be("""{"x":1,"y":2.5}""");
|
||
Json(new double3(1d, 2d, 3d)).Should().Be("""{"x":1,"y":2,"z":3}""");
|
||
Json(new int2(3, -4)).Should().Be("""{"x":3,"y":-4}""");
|
||
Json(new int3(1, 2, 3)).Should().Be("""{"x":1,"y":2,"z":3}""");
|
||
Json(new int4(1, 2, 3, 4)).Should().Be("""{"x":1,"y":2,"z":3,"w":4}""");
|
||
Json(new bool2(true, false)).Should().Be("""{"x":true,"y":false}""");
|
||
Json(new bool3(true, false, true)).Should().Be("""{"x":true,"y":false,"z":true}""");
|
||
Json(new bool4(true, false, false, true)).Should().Be("""{"x":true,"y":false,"z":false,"w":true}""");
|
||
Json(new quaternion(0f, 0f, 0f, 1f)).Should().Be("""{"x":0,"y":0,"z":0,"w":1}""");
|
||
Json(new Color32(255, 128, 0, 7)).Should().Be("""{"r":255,"g":128,"b":0,"a":7}""");
|
||
Json(new CultMath.Random(12345u)).Should().Be("""{"state":12345}""");
|
||
Json(new float2x2(1f, 2f, 3f, 4f)).Should().Be("""[{"x":1,"y":2},{"x":3,"y":4}]""");
|
||
Json(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)).Should().Be(
|
||
"""[{"x":1,"y":2,"z":3},{"x":4,"y":5,"z":6},{"x":7,"y":8,"z":9}]""");
|
||
Json(new rect(5f, 7f, -1f, -2f)).Should().Be("""{"min":{"x":-1,"y":-2},"max":{"x":5,"y":7}}""");
|
||
Json(new CultCellular(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f), 0.25f)).Should().Be(
|
||
"""{"nearest":{"x":1,"y":2,"z":3,"w":4},"edge":{"x":5,"y":6,"z":7,"w":8},"id":0.25}""");
|
||
Json(new CultPhasor(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f))).Should().Be(
|
||
"""{"cosine":{"x":1,"y":2,"z":3,"w":4},"sine":{"x":5,"y":6,"z":7,"w":8}}""");
|
||
Json(new SpanNoise(new float4(1f, 2f, 3f, 4f), 0.25f, 0.5f)).Should().Be(
|
||
"""{"field":{"x":1,"y":2,"z":3,"w":4},"curvature":0.25,"fold":0.5}""");
|
||
Json(new SpanPath { midpoint = new float3(1f, 2f, 3f), velocity = new float3(4f, 5f, 6f), speed = 7f, acceleration = 8f, halfLength = 9f }).Should().Be(
|
||
"""{"midpoint":{"x":1,"y":2,"z":3},"velocity":{"x":4,"y":5,"z":6},"speed":7,"acceleration":8,"halfLength":9}""");
|
||
Json(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(
|
||
"""{"frequency":1,"stretch":2,"axis":{"x":3,"y":4,"z":5},"shift":{"x":6,"y":7,"z":8},"offset":{"x":9,"y":10,"z":11},"octaveStep":{"x":12,"y":13,"z":14}}""");
|
||
Json(new CultSiteCells(new float4(1, 2, 3, 4), new float4(5, 6, 7, 8), 9f, 10f)).Should().Be(
|
||
"""{"nearest":{"x":1,"y":2,"z":3,"w":4},"second":{"x":5,"y":6,"z":7,"w":8},"nearestSite":9,"secondSite":10}""");
|
||
}
|
||
|
||
[Test]
|
||
public void EveryShape_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 Reading_SkipsUnknownPropertiesAndZeroesMissingOnes()
|
||
{
|
||
Parse<float3>("""{"z":3,"extra":[1,2],"x":1}""").Should().Be(new float3(1f, 0f, 3f));
|
||
Parse<Color32>("""{"r":1,"g":2,"b":3}""").Should().Be(new Color32(1, 2, 3, 255));
|
||
Parse<float2?>("null").Should().BeNull();
|
||
}
|
||
|
||
[Test]
|
||
public void Reading_AcceptsTheLegacyPhasorNames()
|
||
{
|
||
// The fixture is what the 0.4.1-era encoder wrote: keys cos/sin.
|
||
var path = System.IO.Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "cultphasor-json-legacy.json");
|
||
var legacy = Parse<CultPhasor[]>(System.IO.File.ReadAllText(path));
|
||
var current = new[]
|
||
{
|
||
new CultPhasor(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f)),
|
||
math.phacelle(new float3(0.3f, 1.7f, -2.2f), new float3(6.2831853f, 0f, 0f), 0.25f, 0.5f),
|
||
};
|
||
legacy.Should().HaveCount(2);
|
||
for (var i = 0; i < 2; i++)
|
||
{
|
||
BitsOf(legacy[i]).Should().Equal(BitsOf(current[i]));
|
||
BitsOf(Parse<CultPhasor>(Json(current[i]))).Should().Equal(BitsOf(current[i]));
|
||
}
|
||
|
||
var caseInsensitive = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }.AddCultMathConverters();
|
||
BitsOf(JsonSerializer.Deserialize<CultPhasor>("""{"COS":{"x":1,"y":2,"z":3,"w":4},"Sin":{"x":5,"y":6,"z":7,"w":8}}""", caseInsensitive))
|
||
.Should().Equal(BitsOf(current[0]));
|
||
}
|
||
|
||
[Test]
|
||
public void Reading_RefusesALegacyAndCurrentNameTogether()
|
||
{
|
||
var property = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties");
|
||
Assert.That(property, Is.Not.Null, "the test runtime's System.Text.Json has AllowDuplicateProperties");
|
||
var strict = new JsonSerializerOptions().AddCultMathConverters();
|
||
property!.SetValue(strict, false);
|
||
Action both = () => JsonSerializer.Deserialize<CultPhasor>("""{"cos":{"x":1},"cosine":{"x":2}}""", strict);
|
||
both.Should().Throw<JsonException>().WithMessage("*appears more than once*");
|
||
Action reversed = () => JsonSerializer.Deserialize<CultPhasor>("""{"sine":{"x":1},"sin":{"x":2}}""", strict);
|
||
reversed.Should().Throw<JsonException>().WithMessage("*appears more than once*");
|
||
|
||
// With duplicates allowed the later property replaces the earlier one, as for any repeated name.
|
||
Parse<CultPhasor>("""{"cos":{"x":1,"y":9},"cosine":{"x":2}}""").cosine.Should().Be(new float4(2f, 0f, 0f, 0f));
|
||
}
|
||
|
||
[Test]
|
||
public void Reading_AcceptsTheLegacyPhasorNamesUnderDisallow()
|
||
{
|
||
var options = new JsonSerializerOptions { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }.AddCultMathConverters();
|
||
var phasor = JsonSerializer.Deserialize<CultPhasor>("""{"cos":{"x":1,"y":2,"z":3,"w":4},"sin":{"x":5,"y":6,"z":7,"w":8}}""", options);
|
||
BitsOf(phasor).Should().Equal(BitsOf(new CultPhasor(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f))));
|
||
Action unknown = () => JsonSerializer.Deserialize<CultPhasor>("""{"cos":{"x":1},"coss":{"x":2}}""", options);
|
||
unknown.Should().Throw<JsonException>().WithMessage("*not a component*");
|
||
}
|
||
|
||
[Test]
|
||
public void Reading_TreatsAThirdOccurrenceOfAPhasorNameAsADuplicate()
|
||
{
|
||
var property = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties");
|
||
Assert.That(property, Is.Not.Null, "the test runtime's System.Text.Json has AllowDuplicateProperties");
|
||
var strict = new JsonSerializerOptions().AddCultMathConverters();
|
||
property!.SetValue(strict, false);
|
||
Action thrice = () => JsonSerializer.Deserialize<CultPhasor>("""{"cos":{"x":1},"cosine":{"x":2},"cos":{"x":3}}""", strict);
|
||
thrice.Should().Throw<JsonException>().WithMessage("*appears more than once*");
|
||
|
||
// Allowed, the last occurrence replaces the whole value, including after a third.
|
||
Parse<CultPhasor>("""{"cos":{"x":1,"y":9},"cosine":{"x":2},"cos":{"z":3}}""").cosine.Should().Be(new float4(0f, 0f, 3f, 0f));
|
||
}
|
||
|
||
[Test]
|
||
public void Reading_SkipsUnknownNamesWhenCaseInsensitive()
|
||
{
|
||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }.AddCultMathConverters();
|
||
JsonSerializer.Deserialize<CultPhasor>("""{"extra":[1,2],"COSINE":{"x":3},"coss":{"x":4}}""", options).cosine.x.Should().Be(3f);
|
||
JsonSerializer.Deserialize<float2>("""{"X":1,"q":2,"Y":3}""", options).Should().Be(new float2(1f, 3f));
|
||
}
|
||
|
||
[Test]
|
||
public void Writing_NeverEmitsLegacyNames()
|
||
{
|
||
using var document = JsonDocument.Parse(Json(new CultPhasor(new float4(1f, 1f, 1f, 1f), new float4(2f, 2f, 2f, 2f))));
|
||
document.RootElement.EnumerateObject().Select(p => p.Name).Should().Equal("cosine", "sine");
|
||
}
|
||
|
||
private static int[] BitsOf(CultPhasor v) => new[] { v.cosine.x, v.cosine.y, v.cosine.z, v.cosine.w, v.sine.x, v.sine.y, v.sine.z, v.sine.w }
|
||
.Select(BitConverter.SingleToInt32Bits).ToArray();
|
||
|
||
[Test]
|
||
public void Reading_RefusesShapesThatAreNotTheType()
|
||
{
|
||
Action array = () => Parse<float2>("[1,2]");
|
||
array.Should().Throw<JsonException>();
|
||
Action shortMatrix = () => Parse<float2x2>("""[{"x":1,"y":2}]""");
|
||
shortMatrix.Should().Throw<JsonException>();
|
||
Action nil = () => Parse<rect>("null");
|
||
nil.Should().Throw<JsonException>();
|
||
}
|
||
|
||
[Test]
|
||
public void EveryPublicCultMathValueType_HasAConverter()
|
||
{
|
||
var valueTypes = typeof(float2).Assembly.GetExportedTypes()
|
||
.Where(t => t.IsValueType && !t.IsEnum)
|
||
.ToHashSet();
|
||
|
||
valueTypes.Except(CultMathJson.ConvertedTypes).Should().BeEmpty();
|
||
CultMathJson.ConvertedTypes.Except(valueTypes).Should().BeEmpty();
|
||
foreach (var type in valueTypes)
|
||
{
|
||
var json = JsonSerializer.Serialize(Activator.CreateInstance(type), type, Options);
|
||
JsonSerializer.Deserialize(json, type, Options).Should().Be(Activator.CreateInstance(type));
|
||
}
|
||
}
|
||
|
||
private static JsonSerializerOptions With(JsonNumberHandling handling = JsonNumberHandling.Strict, bool ignoreCase = false) =>
|
||
new JsonSerializerOptions { NumberHandling = handling, PropertyNameCaseInsensitive = ignoreCase }.AddCultMathConverters();
|
||
|
||
private const JsonNumberHandling Named = JsonNumberHandling.AllowNamedFloatingPointLiterals;
|
||
|
||
[Test]
|
||
public void NonFiniteFloats_NeedNamedLiterals_AsInSystemTextJson()
|
||
{
|
||
Action nan = () => JsonSerializer.Serialize(new float2(float.NaN, 1f), Options);
|
||
nan.Should().Throw<JsonException>();
|
||
Action inf = () => JsonSerializer.Serialize(new double3(1d, double.PositiveInfinity, 0d), Options);
|
||
inf.Should().Throw<JsonException>();
|
||
Action named = () => JsonSerializer.Deserialize<float2>("""{"x":"NaN","y":1}""", Options);
|
||
named.Should().Throw<JsonException>();
|
||
|
||
var options = With(Named);
|
||
JsonSerializer.Serialize(new float2(float.NaN, 1f), options).Should().Be("""{"x":"NaN","y":1}""");
|
||
JsonSerializer.Serialize(new float3(float.PositiveInfinity, float.NegativeInfinity, 0f), options)
|
||
.Should().Be("""{"x":"Infinity","y":"-Infinity","z":0}""");
|
||
var back = JsonSerializer.Deserialize<float3>("""{"x":"Infinity","y":"-Infinity","z":"NaN"}""", options);
|
||
back.x.Should().Be(float.PositiveInfinity);
|
||
back.y.Should().Be(float.NegativeInfinity);
|
||
float.IsNaN(back.z).Should().BeTrue();
|
||
JsonSerializer.Deserialize<double2>("""{"x":"NaN","y":"-Infinity"}""", options).y.Should().Be(double.NegativeInfinity);
|
||
}
|
||
|
||
[Test]
|
||
public void Numbers_OutsideTheComponentType_AreRefused()
|
||
{
|
||
foreach (var json in new[] { """{"x":1e40}""", """{"x":-1e40}""" })
|
||
{
|
||
Action tooBig = () => Parse<float2>(json);
|
||
tooBig.Should().Throw<JsonException>(json);
|
||
}
|
||
|
||
Action doubleTooBig = () => Parse<double2>("""{"x":1e400}""");
|
||
doubleTooBig.Should().Throw<JsonException>();
|
||
Action alpha = () => Parse<Color32>("""{"r":300}""");
|
||
alpha.Should().Throw<JsonException>();
|
||
Action negativeByte = () => Parse<Color32>("""{"g":-1}""");
|
||
negativeByte.Should().Throw<JsonException>();
|
||
Action fraction = () => Parse<int2>("""{"x":1.5}""");
|
||
fraction.Should().Throw<JsonException>();
|
||
Action wrongToken = () => Parse<float2>("""{"x":"a"}""");
|
||
wrongToken.Should().Throw<JsonException>();
|
||
Action boolean = () => Parse<bool2>("""{"x":1}""");
|
||
boolean.Should().Throw<JsonException>();
|
||
Action negativeState = () => Parse<CultMath.Random>("""{"state":-1}""");
|
||
negativeState.Should().Throw<JsonException>();
|
||
}
|
||
|
||
[Test]
|
||
public void NumberHandling_QuotesAndUnquotesNumbers()
|
||
{
|
||
JsonSerializer.Serialize(new float2(1f, 2.5f), With(JsonNumberHandling.WriteAsString)).Should().Be("""{"x":"1","y":"2.5"}""");
|
||
JsonSerializer.Serialize(new int2(1, 2), With(JsonNumberHandling.WriteAsString)).Should().Be("""{"x":"1","y":"2"}""");
|
||
JsonSerializer.Deserialize<float2>("""{"x":"1.5","y":2}""", With(JsonNumberHandling.AllowReadingFromString))
|
||
.Should().Be(new float2(1.5f, 2f));
|
||
JsonSerializer.Deserialize<int2>("""{"x":"7","y":2}""", With(JsonNumberHandling.AllowReadingFromString))
|
||
.Should().Be(new int2(7, 2));
|
||
Action strict = () => Parse<float2>("""{"x":"1.5"}""");
|
||
strict.Should().Throw<JsonException>();
|
||
Action tooBig = () => JsonSerializer.Deserialize<float2>("""{"x":"1e40"}""", With(JsonNumberHandling.AllowReadingFromString));
|
||
tooBig.Should().Throw<JsonException>();
|
||
}
|
||
|
||
[Test]
|
||
public void PropertyNames_FollowTheCaseSensitivityOption()
|
||
{
|
||
JsonSerializer.Deserialize<float2>("""{"X":1,"Y":2}""", With(ignoreCase: true)).Should().Be(new float2(1f, 2f));
|
||
JsonSerializer.Deserialize<rect>("""{"MIN":{"X":1,"y":2},"Max":{"x":3,"Y":4}}""", With(ignoreCase: true))
|
||
.Should().Be(new rect(1f, 2f, 3f, 4f));
|
||
Parse<float2>("""{"X":1,"Y":2}""").Should().Be(float2.zero);
|
||
}
|
||
|
||
[Test]
|
||
public void EveryShape_WorksAsADictionaryKey()
|
||
{
|
||
Json(new Dictionary<int2, string> { [new int2(1, -2)] = "a" }).Should().Be("""{"1,-2":"a"}""");
|
||
Parse<Dictionary<int2, string>>("""{"1,-2":"a"}""")[new int2(1, -2)].Should().Be("a");
|
||
|
||
KeyOf(new float2(1.5f, -2f)).Should().Be("1.5,-2");
|
||
KeyOf(new float3(0.1f, 2f, 3f)).Should().Be("0.1,2,3");
|
||
KeyOf(new bool2(true, false)).Should().Be("true,false");
|
||
KeyOf(new Color32(255, 128, 0, 7)).Should().Be("255,128,0,7");
|
||
KeyOf(new CultMath.Random(12345u)).Should().Be("12345");
|
||
KeyOf(new float2x2(1f, 2f, 3f, 4f)).Should().Be("1,2,3,4");
|
||
KeyOf(new rect(5f, 7f, -1f, -2f)).Should().Be("-1,-2,5,7");
|
||
KeyOf(new float2(float.NaN, float.NegativeInfinity), Named).Should().Be("NaN,-Infinity");
|
||
|
||
var nan = JsonSerializer.Deserialize<Dictionary<float2, int>>("""{"NaN,Infinity":1}""", With(Named))!;
|
||
nan.Keys.Single().y.Should().Be(float.PositiveInfinity);
|
||
|
||
Action refuseNonFinite = () => KeyOf(new float2(float.NaN, 0f));
|
||
refuseNonFinite.Should().Throw<JsonException>();
|
||
Action wrongArity = () => Parse<Dictionary<float3, int>>("""{"1,2":1}""");
|
||
wrongArity.Should().Throw<JsonException>();
|
||
Action overflow = () => Parse<Dictionary<float2, int>>("""{"1e40,0":1}""");
|
||
overflow.Should().Throw<JsonException>();
|
||
}
|
||
|
||
[Test]
|
||
public void EveryPublicCultMathValueType_RoundTripsAsADictionaryKey()
|
||
{
|
||
foreach (var type in typeof(float2).Assembly.GetExportedTypes().Where(t => t.IsValueType && !t.IsEnum))
|
||
{
|
||
var dictionary = (IDictionary)Activator.CreateInstance(typeof(Dictionary<,>).MakeGenericType(type, typeof(int)))!;
|
||
dictionary.Add(Activator.CreateInstance(type)!, 7);
|
||
|
||
var json = JsonSerializer.Serialize(dictionary, dictionary.GetType(), Options);
|
||
var back = (IDictionary)JsonSerializer.Deserialize(json, dictionary.GetType(), Options)!;
|
||
|
||
back.Count.Should().Be(1, type.Name);
|
||
back[Activator.CreateInstance(type)!].Should().Be(7, type.Name);
|
||
}
|
||
}
|
||
|
||
private static string KeyOf<T>(T value, JsonNumberHandling handling = JsonNumberHandling.Strict) where T : notnull
|
||
{
|
||
var json = JsonSerializer.Serialize(new Dictionary<T, int> { [value] = 1 }, With(handling));
|
||
return JsonDocument.Parse(json).RootElement.EnumerateObject().Single().Name;
|
||
}
|
||
|
||
private const JsonNumberHandling ReadStrings = JsonNumberHandling.AllowReadingFromString;
|
||
private const JsonNumberHandling WriteStrings = JsonNumberHandling.WriteAsString;
|
||
|
||
// Every row was measured against System.Text.Json's own float.
|
||
[Test]
|
||
public void NamedLiterals_FollowTheSameFlagsAsSystemTextJsonFloat()
|
||
{
|
||
foreach (var handling in new[] { Named, ReadStrings, ReadStrings | Named })
|
||
{
|
||
JsonSerializer.Deserialize<float2>("""{"x":"NaN","y":"-Infinity"}""", With(handling))!.y.Should().Be(float.NegativeInfinity, handling.ToString());
|
||
}
|
||
|
||
foreach (var handling in new[] { JsonNumberHandling.Strict, WriteStrings })
|
||
{
|
||
Action read = () => JsonSerializer.Deserialize<float2>("""{"x":"NaN"}""", With(handling));
|
||
read.Should().Throw<JsonException>(handling.ToString());
|
||
}
|
||
|
||
foreach (var handling in new[] { Named, WriteStrings, WriteStrings | Named })
|
||
{
|
||
JsonSerializer.Serialize(new float2(float.NaN, float.PositiveInfinity), With(handling))
|
||
.Should().Be("""{"x":"NaN","y":"Infinity"}""", handling.ToString());
|
||
}
|
||
|
||
foreach (var handling in new[] { JsonNumberHandling.Strict, ReadStrings })
|
||
{
|
||
Action write = () => JsonSerializer.Serialize(new float2(float.NaN, 0f), With(handling));
|
||
write.Should().Throw<JsonException>(handling.ToString());
|
||
}
|
||
|
||
JsonSerializer.Serialize(new float2(1f, float.NaN), With(WriteStrings)).Should().Be("""{"x":"1","y":"NaN"}""");
|
||
JsonSerializer.Deserialize<double3>("""{"z":"Infinity"}""", With(ReadStrings))!.z.Should().Be(double.PositiveInfinity);
|
||
JsonSerializer.Serialize(new double2(double.NegativeInfinity, 2d), With(WriteStrings)).Should().Be("""{"x":"-Infinity","y":"2"}""");
|
||
}
|
||
|
||
[Test]
|
||
public void QuotedNumbers_RefuseWhitespace()
|
||
{
|
||
var options = With(ReadStrings);
|
||
foreach (var json in new[] { """{"x":" 1.5"}""", """{"x":"1.5 "}""", """{"x":"\t1"}""" })
|
||
{
|
||
Action floatRead = () => JsonSerializer.Deserialize<float2>(json, options);
|
||
floatRead.Should().Throw<JsonException>(json);
|
||
}
|
||
|
||
Action doubleRead = () => JsonSerializer.Deserialize<double2>("""{"y":" 2"}""", options);
|
||
doubleRead.Should().Throw<JsonException>();
|
||
Action intRead = () => JsonSerializer.Deserialize<int2>("""{"x":" 1"}""", options);
|
||
intRead.Should().Throw<JsonException>();
|
||
Action padded = () => JsonSerializer.Deserialize<float2>("""{"x":" NaN"}""", With(ReadStrings | Named));
|
||
padded.Should().Throw<JsonException>();
|
||
}
|
||
|
||
[Test]
|
||
public void UnknownProperties_AreSkippedWhateverTheyContain()
|
||
{
|
||
var expected = new float3(1f, 2f, 3f);
|
||
foreach (var json in new[]
|
||
{
|
||
"""{"u":{"a":[1,{"b":2}]},"x":1,"y":2,"z":3}""",
|
||
"""{"x":1,"u":[[1],[2,[3]]],"y":2,"v":{"a":{}},"z":3}""",
|
||
"""{"x":1,"y":2,"z":3,"u":{"a":{"b":[]}}}""",
|
||
"""{"x":1,"y":2,"z":3,"u":[{"a":[{}]}]}""",
|
||
})
|
||
{
|
||
Parse<float3>(json).Should().Be(expected, json);
|
||
}
|
||
|
||
Parse<rect>("""{"min":{"q":[{}],"x":1,"y":2},"skip":{"a":[1]},"max":{"x":3,"y":4,"q":{"a":[]}}}""")
|
||
.Should().Be(new rect(1f, 2f, 3f, 4f));
|
||
Parse<float2x2>("""[{"x":1,"u":[1],"y":2},{"y":4,"u":{"a":1},"x":3}]""").Should().Be(new float2x2(1f, 2f, 3f, 4f));
|
||
}
|
||
|
||
[Test]
|
||
public void UnmappedMemberHandling_Disallow_RefusesUnknownProperties()
|
||
{
|
||
var options = new JsonSerializerOptions { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }.AddCultMathConverters();
|
||
Action flat = () => JsonSerializer.Deserialize<float2>("""{"x":1,"z":3}""", options);
|
||
flat.Should().Throw<JsonException>();
|
||
Action nested = () => JsonSerializer.Deserialize<rect>("""{"min":{"x":1,"q":1},"max":{}}""", options);
|
||
nested.Should().Throw<JsonException>();
|
||
JsonSerializer.Deserialize<float2>("""{"x":1,"y":2}""", options).Should().Be(new float2(1f, 2f));
|
||
}
|
||
|
||
[Test]
|
||
public void DuplicateProperties_ReplaceTheWholeValue()
|
||
{
|
||
Parse<float2>("""{"x":1,"x":2,"y":5}""").Should().Be(new float2(2f, 5f));
|
||
Parse<rect>("""{"min":{"x":1,"y":2},"min":{"x":3},"max":{"x":4,"y":5}}""").Should().Be(new rect(3f, 0f, 4f, 5f));
|
||
Parse<Color32>("""{"a":9,"a":1}""").Should().Be(new Color32(0, 0, 0, 1));
|
||
Parse<Color32>("""{"r":3,"r":4}""").Should().Be(new Color32(4, 0, 0, 255));
|
||
Parse<CultCellular>("""{"nearest":{"x":1,"y":2},"nearest":{"z":3},"id":1}""")
|
||
.Should().Be(new CultCellular(new float4(0f, 0f, 3f, 0f), float4.zero, 1f));
|
||
}
|
||
|
||
[Test]
|
||
public void AllowDuplicatePropertiesFalse_RefusesRepeats()
|
||
{
|
||
var property = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties");
|
||
Assert.That(property, Is.Not.Null, "the test runtime's System.Text.Json has AllowDuplicateProperties");
|
||
var options = new JsonSerializerOptions().AddCultMathConverters();
|
||
property!.SetValue(options, false);
|
||
|
||
Action flat = () => JsonSerializer.Deserialize<float2>("""{"x":1,"x":2}""", options);
|
||
flat.Should().Throw<JsonException>();
|
||
Action nested = () => JsonSerializer.Deserialize<rect>("""{"min":{"x":1,"x":2}}""", options);
|
||
nested.Should().Throw<JsonException>();
|
||
JsonSerializer.Deserialize<float2>("""{"x":1,"y":2}""", options).Should().Be(new float2(1f, 2f));
|
||
}
|
||
|
||
[Test]
|
||
public void DictionaryKeys_ReadOnlyTheCanonicalSpelling()
|
||
{
|
||
foreach (var key in new[] { "1.0,2", "+1,2", " 1,2", "1, 2", "01,2", "1,2 ", "1e0,2", "1,2,", ",1" })
|
||
{
|
||
Action read = () => Parse<Dictionary<float2, int>>($$"""{"{{key}}":1}""");
|
||
read.Should().Throw<JsonException>(key);
|
||
}
|
||
|
||
foreach (var key in new[] { "01,2", "+1,2", "1,-0" })
|
||
{
|
||
Action read = () => Parse<Dictionary<int2, int>>($$"""{"{{key}}":1}""");
|
||
read.Should().Throw<JsonException>(key);
|
||
}
|
||
|
||
Action bools = () => Parse<Dictionary<bool2, int>>("""{"True,false":1}""");
|
||
bools.Should().Throw<JsonException>();
|
||
Parse<Dictionary<float2, int>>("""{"1,2":1}""").Should().ContainKey(new float2(1f, 2f));
|
||
}
|
||
|
||
[Test]
|
||
public void NegativeZero_IsADistinctKey()
|
||
{
|
||
var negative = new float2(-0f, 0f);
|
||
KeyOf(negative).Should().Be("-0,0");
|
||
KeyOf(float2.zero).Should().Be("0,0");
|
||
KeyOf(new double2(0d, -0d)).Should().Be("0,-0");
|
||
|
||
var parsed = Parse<Dictionary<float2, int>>("""{"-0,0":1}""").Keys.Single();
|
||
float.IsNegative(parsed.x).Should().BeTrue();
|
||
var positive = Parse<Dictionary<float2, int>>("""{"0,0":1}""").Keys.Single();
|
||
float.IsNegative(positive.x).Should().BeFalse();
|
||
}
|
||
|
||
[Test]
|
||
public void Culture_DoesNotChangeKeysOrValues()
|
||
{
|
||
var german = (CultureInfo)CultureInfo.InvariantCulture.Clone();
|
||
german.NumberFormat.NumberDecimalSeparator = ",";
|
||
german.NumberFormat.NumberGroupSeparator = ".";
|
||
german.NumberFormat.NegativeSign = "−";
|
||
german.NumberFormat.PositiveSign = "➕";
|
||
var swedish = (CultureInfo)CultureInfo.InvariantCulture.Clone();
|
||
swedish.NumberFormat.NegativeSign = "−";
|
||
swedish.NumberFormat.NumberDecimalSeparator = ",";
|
||
|
||
var original = CultureInfo.CurrentCulture;
|
||
try
|
||
{
|
||
foreach (var culture in new[] { german, swedish })
|
||
{
|
||
CultureInfo.CurrentCulture = culture;
|
||
var options = With(ReadStrings | WriteStrings | Named);
|
||
|
||
KeyOf(new float2(-1.5f, 2.25f)).Should().Be("-1.5,2.25");
|
||
KeyOf(new double3(-1e-7, 1234.5, -0d)).Should().Be("-1E-07,1234.5,-0");
|
||
KeyOf(new int2(-3, 4)).Should().Be("-3,4");
|
||
KeyOf(new float2(float.NaN, float.NegativeInfinity), Named).Should().Be("NaN,-Infinity");
|
||
Parse<Dictionary<float2, int>>("""{"-1.5,2.25":1}""").Keys.Single().Should().Be(new float2(-1.5f, 2.25f));
|
||
Parse<Dictionary<int2, int>>("""{"-3,4":1}""").Keys.Single().Should().Be(new int2(-3, 4));
|
||
|
||
JsonSerializer.Serialize(new float2(-1.5f, 2.25f), options).Should().Be("""{"x":"-1.5","y":"2.25"}""");
|
||
JsonSerializer.Deserialize<float2>("""{"x":"-1.5","y":"2.25"}""", options).Should().Be(new float2(-1.5f, 2.25f));
|
||
JsonSerializer.Serialize(new int2(-3, 4), options).Should().Be("""{"x":"-3","y":"4"}""");
|
||
JsonSerializer.Deserialize<int2>("""{"x":"-3","y":"4"}""", options).Should().Be(new int2(-3, 4));
|
||
Json(new float2(-1.5f, 2.25f)).Should().Be("""{"x":-1.5,"y":2.25}""");
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
CultureInfo.CurrentCulture = original;
|
||
}
|
||
}
|
||
|
||
private static void RoundTrip<T>(T value) => Parse<T>(Json(value)).Should().Be(value);
|
||
}
|
||
}
|