Bring the CultMath JSON converters to System.Text.Json parity and remove their allocations
Named literals now follow AllowReadingFromString and WriteAsString as System.Text.Json's float does; quoted numbers refuse whitespace; UnmappedMemberHandling.Disallow and AllowDuplicateProperties=false are honoured and a repeated property replaces the whole value; dictionary keys read only the canonical spelling. Components live in stack spans, property names are pre-encoded, and there is no per-property closure, so a float3 read allocates nothing (424 bytes before). Tests pin unknown nested values, de-DE and sv-SE style cultures, and a distinct negative-zero key. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
fee55e89c5
commit
4e5327c61c
3 changed files with 424 additions and 209 deletions
|
|
@ -1,6 +1,8 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using CultMath;
|
||||
|
|
@ -16,22 +18,30 @@ namespace GameCult.Caching.MessagePack;
|
|||
/// Property names are fixed: <c>PropertyNamingPolicy</c> does not rename them, and
|
||||
/// <c>PropertyNameCaseInsensitive</c> makes reading case-insensitive.
|
||||
/// <para>
|
||||
/// Reading skips unknown properties and leaves missing ones at zero (a missing <c>Color32.a</c>
|
||||
/// is 255). A wrong token, a number outside its component type (including a float beyond
|
||||
/// <see cref="float.MaxValue"/>, which is refused rather than becoming infinity), or a matrix
|
||||
/// that is not an array of exactly its row count throws <see cref="JsonException"/>.
|
||||
/// Reading skips unknown properties (<c>UnmappedMemberHandling.Disallow</c> refuses them) and
|
||||
/// leaves missing ones at zero (a missing <c>Color32.a</c> is 255). A repeated property replaces
|
||||
/// the whole earlier value, never merging component-wise, and is refused when
|
||||
/// <c>AllowDuplicateProperties</c> is false. A wrong token, a number outside its component type,
|
||||
/// or a matrix that is not an array of exactly its row count throws
|
||||
/// <see cref="JsonException"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Numbers follow <see cref="JsonSerializerOptions.NumberHandling"/> as System.Text.Json's own
|
||||
/// float does: NaN and the infinities need <c>AllowNamedFloatingPointLiterals</c> (written and
|
||||
/// read as <c>"NaN"</c>, <c>"Infinity"</c>, <c>"-Infinity"</c>) and are otherwise refused with
|
||||
/// <see cref="JsonException"/>; <c>AllowReadingFromString</c> reads quoted numbers;
|
||||
/// <c>WriteAsString</c> writes quoted numbers.
|
||||
/// float does. NaN and the infinities are the strings <c>"NaN"</c>, <c>"Infinity"</c>,
|
||||
/// <c>"-Infinity"</c>: written when <c>AllowNamedFloatingPointLiterals</c> or
|
||||
/// <c>WriteAsString</c> is set (otherwise <see cref="JsonException"/>), read when
|
||||
/// <c>AllowNamedFloatingPointLiterals</c> or <c>AllowReadingFromString</c> is set.
|
||||
/// <c>AllowReadingFromString</c> reads quoted numbers without surrounding whitespace, and
|
||||
/// <c>WriteAsString</c> writes quoted numbers. Two intended divergences: a bare number beyond
|
||||
/// the component's range (<c>1e40</c> as a float) is refused where System.Text.Json reads
|
||||
/// infinity, and refusals on write are <see cref="JsonException"/> where it throws
|
||||
/// <see cref="ArgumentException"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every type is also a dictionary key: the flattened components joined by commas in
|
||||
/// declaration order, invariant culture, shortest round-trip floats (<c>"1,2"</c>,
|
||||
/// <c>"true,false"</c>, matrices row by row, <c>rect</c> as <c>"minX,minY,maxX,maxY"</c>).
|
||||
/// Only that exact spelling reads back, so two JSON keys never collapse into one dictionary key.
|
||||
/// </para>
|
||||
/// Register with <see cref="AddCultMathConverters"/>; JSON options are the consumer's own.
|
||||
/// </summary>
|
||||
|
|
@ -48,150 +58,141 @@ public static class CultMathJson
|
|||
return options;
|
||||
}
|
||||
|
||||
private static bool Named(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.AllowNamedFloatingPointLiterals) != 0;
|
||||
private static bool LiteralsWrite(JsonSerializerOptions o) =>
|
||||
(o.NumberHandling & (JsonNumberHandling.AllowNamedFloatingPointLiterals | JsonNumberHandling.WriteAsString)) != 0;
|
||||
|
||||
private static bool LiteralsRead(JsonSerializerOptions o) =>
|
||||
(o.NumberHandling & (JsonNumberHandling.AllowNamedFloatingPointLiterals | JsonNumberHandling.AllowReadingFromString)) != 0;
|
||||
|
||||
private static bool FromString(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.AllowReadingFromString) != 0;
|
||||
private static bool AsString(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.WriteAsString) != 0;
|
||||
|
||||
private static string Invalid(string what, string got) => $"{what} cannot be read from '{got}'.";
|
||||
// AllowDuplicateProperties arrived after the System.Text.Json this library compiles against.
|
||||
private static readonly PropertyInfo? AllowDuplicateProperties = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties");
|
||||
|
||||
private static bool DuplicatesAllowed(JsonSerializerOptions o) => AllowDuplicateProperties == null || (bool)AllowDuplicateProperties.GetValue(o)!;
|
||||
|
||||
private const NumberStyles FloatStyle = NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent;
|
||||
|
||||
// One component kind. Every CultMath type is a flat list of one kind, so a Scalar plus a Node tree describes it.
|
||||
private abstract class Scalar<S>
|
||||
private abstract class Scalar<S> where S : unmanaged
|
||||
{
|
||||
public virtual S[] Defaults(int count) => new S[count];
|
||||
public abstract void Write(Utf8JsonWriter w, S value, JsonSerializerOptions o);
|
||||
public abstract S Read(ref Utf8JsonReader r, JsonSerializerOptions o);
|
||||
public abstract string Text(S value, JsonSerializerOptions o);
|
||||
|
||||
/// <summary>The canonical key spelling, whatever the options allow.</summary>
|
||||
public abstract string Spelling(S value);
|
||||
|
||||
/// <summary>The key spelling, refusing what the options would refuse to write.</summary>
|
||||
public virtual string Text(S value, JsonSerializerOptions o) => Spelling(value);
|
||||
|
||||
public abstract S Parse(string text, JsonSerializerOptions o);
|
||||
|
||||
/// <summary>Reads a key component; keys are always text, so AllowReadingFromString does not apply.</summary>
|
||||
public virtual S ParseKey(string text, JsonSerializerOptions o) => Parse(text, o);
|
||||
}
|
||||
|
||||
private sealed class FloatScalar : Scalar<float>
|
||||
private abstract class FloatingScalar<S> : Scalar<S> where S : unmanaged
|
||||
{
|
||||
protected static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity";
|
||||
protected abstract bool IsFinite(S value);
|
||||
protected abstract string Literal(S value);
|
||||
protected abstract S FromLiteral(string literal);
|
||||
protected abstract bool TryNumber(string text, out S value);
|
||||
protected abstract bool TryToken(ref Utf8JsonReader r, out S value);
|
||||
protected abstract void WriteNumber(Utf8JsonWriter w, S value);
|
||||
protected abstract string Number(S value);
|
||||
|
||||
public override void Write(Utf8JsonWriter w, S v, JsonSerializerOptions o)
|
||||
{
|
||||
if (!IsFinite(v))
|
||||
{
|
||||
if (!LiteralsWrite(o)) throw new JsonException($"A non-finite {typeof(S).Name} needs JsonNumberHandling.AllowNamedFloatingPointLiterals or WriteAsString.");
|
||||
w.WriteStringValue(Literal(v));
|
||||
}
|
||||
else if (AsString(o)) w.WriteStringValue(Number(v));
|
||||
else WriteNumber(w, v);
|
||||
}
|
||||
|
||||
public override S Read(ref Utf8JsonReader r, JsonSerializerOptions o)
|
||||
{
|
||||
if (r.TokenType == JsonTokenType.Number)
|
||||
{
|
||||
if (!TryToken(ref r, out var v) || !IsFinite(v)) throw new JsonException($"The number is outside the {typeof(S).Name} range.");
|
||||
return v;
|
||||
}
|
||||
|
||||
if (r.TokenType == JsonTokenType.String) return Parse(r.GetString()!, o);
|
||||
throw new JsonException($"Expected a number, found {r.TokenType}.");
|
||||
}
|
||||
|
||||
public override string Spelling(S v) => Number(v);
|
||||
|
||||
public override string Text(S v, JsonSerializerOptions o)
|
||||
{
|
||||
if (!IsFinite(v) && !LiteralsWrite(o)) throw new JsonException($"A non-finite {typeof(S).Name} needs JsonNumberHandling.AllowNamedFloatingPointLiterals or WriteAsString.");
|
||||
return Spelling(v);
|
||||
}
|
||||
|
||||
public override S Parse(string text, JsonSerializerOptions o)
|
||||
{
|
||||
if (!IsLiteral(text) && !FromString(o)) throw new JsonException($"Expected a number, found the string '{text}'.");
|
||||
return ParseKey(text, o);
|
||||
}
|
||||
|
||||
public override S ParseKey(string text, JsonSerializerOptions o)
|
||||
{
|
||||
if (IsLiteral(text))
|
||||
{
|
||||
if (!LiteralsRead(o)) throw new JsonException($"'{text}' needs JsonNumberHandling.AllowNamedFloatingPointLiterals or AllowReadingFromString.");
|
||||
return FromLiteral(text);
|
||||
}
|
||||
|
||||
if (TryNumber(text, out var v) && IsFinite(v)) return v;
|
||||
throw new JsonException($"A {typeof(S).Name} cannot be read from '{text}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FloatScalar : FloatingScalar<float>
|
||||
{
|
||||
public static readonly FloatScalar Instance = new();
|
||||
|
||||
private static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity";
|
||||
|
||||
private static string Literal(float v) => float.IsNaN(v) ? "NaN" : v > 0f ? "Infinity" : "-Infinity";
|
||||
|
||||
public override void Write(Utf8JsonWriter w, float v, JsonSerializerOptions o)
|
||||
{
|
||||
if (!float.IsFinite(v))
|
||||
{
|
||||
if (!Named(o)) throw new JsonException("A non-finite float needs JsonNumberHandling.AllowNamedFloatingPointLiterals.");
|
||||
w.WriteStringValue(Literal(v));
|
||||
}
|
||||
else if (AsString(o)) w.WriteStringValue(v.ToString("R", CultureInfo.InvariantCulture));
|
||||
else w.WriteNumberValue(v);
|
||||
}
|
||||
|
||||
public override float Read(ref Utf8JsonReader r, JsonSerializerOptions o)
|
||||
{
|
||||
if (r.TokenType == JsonTokenType.Number)
|
||||
{
|
||||
if (!r.TryGetSingle(out var v) || float.IsInfinity(v)) throw new JsonException("The number is outside the float range.");
|
||||
return v;
|
||||
}
|
||||
|
||||
if (r.TokenType == JsonTokenType.String)
|
||||
{
|
||||
var text = r.GetString()!;
|
||||
if (!FromString(o) && !(Named(o) && IsLiteral(text))) throw new JsonException($"Expected a number, found the string '{text}'.");
|
||||
return Parse(text, o);
|
||||
}
|
||||
|
||||
throw new JsonException($"Expected a number, found {r.TokenType}.");
|
||||
}
|
||||
|
||||
public override string Text(float v, JsonSerializerOptions o)
|
||||
{
|
||||
if (!float.IsFinite(v) && !Named(o)) throw new JsonException("A non-finite float needs JsonNumberHandling.AllowNamedFloatingPointLiterals.");
|
||||
return v.ToString("R", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public override float Parse(string text, JsonSerializerOptions o)
|
||||
{
|
||||
if (Named(o))
|
||||
{
|
||||
if (text == "NaN") return float.NaN;
|
||||
if (text == "Infinity") return float.PositiveInfinity;
|
||||
if (text == "-Infinity") return float.NegativeInfinity;
|
||||
}
|
||||
|
||||
if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) && float.IsFinite(v)) return v;
|
||||
throw new JsonException(Invalid("A float", text));
|
||||
}
|
||||
protected override bool IsFinite(float v) => float.IsFinite(v);
|
||||
protected override string Literal(float v) => float.IsNaN(v) ? "NaN" : v > 0f ? "Infinity" : "-Infinity";
|
||||
protected override float FromLiteral(string s) => s == "NaN" ? float.NaN : s == "Infinity" ? float.PositiveInfinity : float.NegativeInfinity;
|
||||
protected override bool TryNumber(string s, out float v) => float.TryParse(s, FloatStyle, CultureInfo.InvariantCulture, out v);
|
||||
protected override bool TryToken(ref Utf8JsonReader r, out float v) => r.TryGetSingle(out v);
|
||||
protected override void WriteNumber(Utf8JsonWriter w, float v) => w.WriteNumberValue(v);
|
||||
protected override string Number(float v) => v.ToString("R", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private sealed class DoubleScalar : Scalar<double>
|
||||
private sealed class DoubleScalar : FloatingScalar<double>
|
||||
{
|
||||
public static readonly DoubleScalar Instance = new();
|
||||
|
||||
private static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity";
|
||||
|
||||
private static string Literal(double v) => double.IsNaN(v) ? "NaN" : v > 0d ? "Infinity" : "-Infinity";
|
||||
|
||||
public override void Write(Utf8JsonWriter w, double v, JsonSerializerOptions o)
|
||||
{
|
||||
if (!double.IsFinite(v))
|
||||
{
|
||||
if (!Named(o)) throw new JsonException("A non-finite double needs JsonNumberHandling.AllowNamedFloatingPointLiterals.");
|
||||
w.WriteStringValue(Literal(v));
|
||||
}
|
||||
else if (AsString(o)) w.WriteStringValue(v.ToString("R", CultureInfo.InvariantCulture));
|
||||
else w.WriteNumberValue(v);
|
||||
}
|
||||
|
||||
public override double Read(ref Utf8JsonReader r, JsonSerializerOptions o)
|
||||
{
|
||||
if (r.TokenType == JsonTokenType.Number)
|
||||
{
|
||||
if (!r.TryGetDouble(out var v) || double.IsInfinity(v)) throw new JsonException("The number is outside the double range.");
|
||||
return v;
|
||||
}
|
||||
|
||||
if (r.TokenType == JsonTokenType.String)
|
||||
{
|
||||
var text = r.GetString()!;
|
||||
if (!FromString(o) && !(Named(o) && IsLiteral(text))) throw new JsonException($"Expected a number, found the string '{text}'.");
|
||||
return Parse(text, o);
|
||||
}
|
||||
|
||||
throw new JsonException($"Expected a number, found {r.TokenType}.");
|
||||
}
|
||||
|
||||
public override string Text(double v, JsonSerializerOptions o)
|
||||
{
|
||||
if (!double.IsFinite(v) && !Named(o)) throw new JsonException("A non-finite double needs JsonNumberHandling.AllowNamedFloatingPointLiterals.");
|
||||
return v.ToString("R", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public override double Parse(string text, JsonSerializerOptions o)
|
||||
{
|
||||
if (Named(o))
|
||||
{
|
||||
if (text == "NaN") return double.NaN;
|
||||
if (text == "Infinity") return double.PositiveInfinity;
|
||||
if (text == "-Infinity") return double.NegativeInfinity;
|
||||
}
|
||||
|
||||
if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) && double.IsFinite(v)) return v;
|
||||
throw new JsonException(Invalid("A double", text));
|
||||
}
|
||||
protected override bool IsFinite(double v) => double.IsFinite(v);
|
||||
protected override string Literal(double v) => double.IsNaN(v) ? "NaN" : v > 0d ? "Infinity" : "-Infinity";
|
||||
protected override double FromLiteral(string s) => s == "NaN" ? double.NaN : s == "Infinity" ? double.PositiveInfinity : double.NegativeInfinity;
|
||||
protected override bool TryNumber(string s, out double v) => double.TryParse(s, FloatStyle, CultureInfo.InvariantCulture, out v);
|
||||
protected override bool TryToken(ref Utf8JsonReader r, out double v) => r.TryGetDouble(out v);
|
||||
protected override void WriteNumber(Utf8JsonWriter w, double v) => w.WriteNumberValue(v);
|
||||
protected override string Number(double v) => v.ToString("R", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private sealed class IntScalar : Scalar<int>
|
||||
private abstract class IntegerScalar<S> : Scalar<S> where S : unmanaged
|
||||
{
|
||||
public static readonly IntScalar Instance = new();
|
||||
protected abstract bool TryToken(ref Utf8JsonReader r, out S value);
|
||||
protected abstract bool TryText(string text, out S value);
|
||||
protected abstract void WriteNumber(Utf8JsonWriter w, S value);
|
||||
|
||||
public override void Write(Utf8JsonWriter w, int v, JsonSerializerOptions o)
|
||||
public override void Write(Utf8JsonWriter w, S v, JsonSerializerOptions o)
|
||||
{
|
||||
if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v);
|
||||
if (AsString(o)) w.WriteStringValue(Spelling(v)); else WriteNumber(w, v);
|
||||
}
|
||||
|
||||
public override int Read(ref Utf8JsonReader r, JsonSerializerOptions o)
|
||||
public override S Read(ref Utf8JsonReader r, JsonSerializerOptions o)
|
||||
{
|
||||
if (r.TokenType == JsonTokenType.Number)
|
||||
{
|
||||
if (!r.TryGetInt32(out var v)) throw new JsonException("The number is not an int.");
|
||||
if (!TryToken(ref r, out var v)) throw new JsonException($"The number is not a {typeof(S).Name}.");
|
||||
return v;
|
||||
}
|
||||
|
||||
|
|
@ -199,71 +200,35 @@ public static class CultMathJson
|
|||
throw new JsonException($"Expected an integer, found {r.TokenType}.");
|
||||
}
|
||||
|
||||
public override string Text(int v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
public override int Parse(string text, JsonSerializerOptions o) =>
|
||||
int.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("An int", text));
|
||||
public override S Parse(string text, JsonSerializerOptions o) =>
|
||||
TryText(text, out var v) ? v : throw new JsonException($"A {typeof(S).Name} cannot be read from '{text}'.");
|
||||
}
|
||||
|
||||
private sealed class UIntScalar : Scalar<uint>
|
||||
private sealed class IntScalar : IntegerScalar<int>
|
||||
{
|
||||
public static readonly IntScalar Instance = new();
|
||||
protected override bool TryToken(ref Utf8JsonReader r, out int v) => r.TryGetInt32(out v);
|
||||
protected override bool TryText(string s, out int v) => int.TryParse(s, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out v);
|
||||
protected override void WriteNumber(Utf8JsonWriter w, int v) => w.WriteNumberValue(v);
|
||||
public override string Spelling(int v) => v.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private sealed class UIntScalar : IntegerScalar<uint>
|
||||
{
|
||||
public static readonly UIntScalar Instance = new();
|
||||
|
||||
public override void Write(Utf8JsonWriter w, uint v, JsonSerializerOptions o)
|
||||
{
|
||||
if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v);
|
||||
}
|
||||
|
||||
public override uint Read(ref Utf8JsonReader r, JsonSerializerOptions o)
|
||||
{
|
||||
if (r.TokenType == JsonTokenType.Number)
|
||||
{
|
||||
if (!r.TryGetUInt32(out var v)) throw new JsonException("The number is not a uint.");
|
||||
return v;
|
||||
}
|
||||
|
||||
if (r.TokenType == JsonTokenType.String && FromString(o)) return Parse(r.GetString()!, o);
|
||||
throw new JsonException($"Expected an unsigned integer, found {r.TokenType}.");
|
||||
}
|
||||
|
||||
public override string Text(uint v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
public override uint Parse(string text, JsonSerializerOptions o) =>
|
||||
uint.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("A uint", text));
|
||||
protected override bool TryToken(ref Utf8JsonReader r, out uint v) => r.TryGetUInt32(out v);
|
||||
protected override bool TryText(string s, out uint v) => uint.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out v);
|
||||
protected override void WriteNumber(Utf8JsonWriter w, uint v) => w.WriteNumberValue(v);
|
||||
public override string Spelling(uint v) => v.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private sealed class ByteScalar : Scalar<byte>
|
||||
private sealed class ByteScalar : IntegerScalar<byte>
|
||||
{
|
||||
public static readonly ByteScalar Instance = new();
|
||||
|
||||
public override byte[] Defaults(int count)
|
||||
{
|
||||
var defaults = new byte[count];
|
||||
if (count == 4) defaults[3] = 255; // Color32 alpha
|
||||
return defaults;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter w, byte v, JsonSerializerOptions o)
|
||||
{
|
||||
if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v);
|
||||
}
|
||||
|
||||
public override byte Read(ref Utf8JsonReader r, JsonSerializerOptions o)
|
||||
{
|
||||
if (r.TokenType == JsonTokenType.Number)
|
||||
{
|
||||
if (!r.TryGetByte(out var v)) throw new JsonException("The number is not a byte.");
|
||||
return v;
|
||||
}
|
||||
|
||||
if (r.TokenType == JsonTokenType.String && FromString(o)) return Parse(r.GetString()!, o);
|
||||
throw new JsonException($"Expected a byte, found {r.TokenType}.");
|
||||
}
|
||||
|
||||
public override string Text(byte v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
public override byte Parse(string text, JsonSerializerOptions o) =>
|
||||
byte.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("A byte", text));
|
||||
protected override bool TryToken(ref Utf8JsonReader r, out byte v) => r.TryGetByte(out v);
|
||||
protected override bool TryText(string s, out byte v) => byte.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out v);
|
||||
protected override void WriteNumber(Utf8JsonWriter w, byte v) => w.WriteNumberValue(v);
|
||||
public override string Spelling(byte v) => v.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private sealed class BoolScalar : Scalar<bool>
|
||||
|
|
@ -277,76 +242,115 @@ public static class CultMathJson
|
|||
: r.TokenType == JsonTokenType.False ? false
|
||||
: throw new JsonException($"Expected true or false, found {r.TokenType}.");
|
||||
|
||||
public override string Text(bool v, JsonSerializerOptions o) => v ? "true" : "false";
|
||||
public override string Spelling(bool v) => v ? "true" : "false";
|
||||
|
||||
public override bool Parse(string text, JsonSerializerOptions o) =>
|
||||
text == "true" ? true : text == "false" ? false : throw new JsonException(Invalid("A bool", text));
|
||||
text == "true" ? true : text == "false" ? false : throw new JsonException($"A bool cannot be read from '{text}'.");
|
||||
}
|
||||
|
||||
// The JSON structure over a flat component list: leaves consume one component each, in order.
|
||||
private abstract class Node<S>
|
||||
// Read fills `flat` (pre-loaded with `defaults`), so a missing property keeps its default.
|
||||
private abstract class Node<S> where S : unmanaged
|
||||
{
|
||||
public int Count { get; protected set; }
|
||||
public abstract void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar<S> scalar, JsonSerializerOptions o);
|
||||
public abstract void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar<S> scalar, JsonSerializerOptions o);
|
||||
public abstract void Write(Utf8JsonWriter w, ReadOnlySpan<S> flat, Scalar<S> scalar, JsonSerializerOptions o);
|
||||
public abstract void Read(ref Utf8JsonReader r, scoped Span<S> flat, scoped ReadOnlySpan<S> defaults, Scalar<S> scalar, JsonSerializerOptions o);
|
||||
}
|
||||
|
||||
private sealed class Leaf<S> : Node<S>
|
||||
private sealed class Leaf<S> : Node<S> where S : unmanaged
|
||||
{
|
||||
public Leaf() { Count = 1; }
|
||||
|
||||
public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar<S> scalar, JsonSerializerOptions o) => scalar.Write(w, flat[offset], o);
|
||||
public override void Write(Utf8JsonWriter w, ReadOnlySpan<S> flat, Scalar<S> scalar, JsonSerializerOptions o) => scalar.Write(w, flat[0], o);
|
||||
|
||||
public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar<S> scalar, JsonSerializerOptions o) => flat[offset] = scalar.Read(ref r, o);
|
||||
public override void Read(ref Utf8JsonReader r, scoped Span<S> flat, scoped ReadOnlySpan<S> defaults, Scalar<S> scalar, JsonSerializerOptions o) => flat[0] = scalar.Read(ref r, o);
|
||||
}
|
||||
|
||||
private sealed class Fields<S> : Node<S>
|
||||
private sealed class Fields<S> : Node<S> where S : unmanaged
|
||||
{
|
||||
private readonly string[] names;
|
||||
private readonly byte[][] utf8Names;
|
||||
private readonly JsonEncodedText[] encoded;
|
||||
private readonly Node<S>[] children;
|
||||
private readonly int[] offsets;
|
||||
|
||||
public Fields(params (string Name, Node<S> Child)[] fields)
|
||||
{
|
||||
names = new string[fields.Length];
|
||||
utf8Names = new byte[fields.Length][];
|
||||
encoded = new JsonEncodedText[fields.Length];
|
||||
children = new Node<S>[fields.Length];
|
||||
offsets = new int[fields.Length];
|
||||
for (var i = 0; i < fields.Length; i++)
|
||||
{
|
||||
names[i] = fields[i].Name;
|
||||
utf8Names[i] = Encoding.UTF8.GetBytes(fields[i].Name);
|
||||
encoded[i] = JsonEncodedText.Encode(fields[i].Name);
|
||||
children[i] = fields[i].Child;
|
||||
offsets[i] = Count;
|
||||
Count += fields[i].Child.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar<S> scalar, JsonSerializerOptions o)
|
||||
public override void Write(Utf8JsonWriter w, ReadOnlySpan<S> flat, Scalar<S> scalar, JsonSerializerOptions o)
|
||||
{
|
||||
w.WriteStartObject();
|
||||
for (var i = 0; i < names.Length; i++)
|
||||
for (var i = 0; i < children.Length; i++)
|
||||
{
|
||||
w.WritePropertyName(names[i]);
|
||||
children[i].Write(w, flat, offset + offsets[i], scalar, o);
|
||||
w.WritePropertyName(encoded[i]);
|
||||
children[i].Write(w, flat.Slice(offsets[i], children[i].Count), scalar, o);
|
||||
}
|
||||
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar<S> scalar, JsonSerializerOptions o)
|
||||
public override void Read(ref Utf8JsonReader r, scoped Span<S> flat, scoped ReadOnlySpan<S> defaults, Scalar<S> scalar, JsonSerializerOptions o)
|
||||
{
|
||||
if (r.TokenType != JsonTokenType.StartObject) throw new JsonException($"Expected a JSON object, found {r.TokenType}.");
|
||||
var comparison = o.PropertyNameCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
var ignoreCase = o.PropertyNameCaseInsensitive;
|
||||
var seen = 0;
|
||||
while (r.Read() && r.TokenType == JsonTokenType.PropertyName)
|
||||
{
|
||||
var name = r.GetString()!;
|
||||
var index = Array.FindIndex(names, n => string.Equals(n, name, comparison));
|
||||
var index = IndexOf(ref r, ignoreCase);
|
||||
r.Read();
|
||||
if (index < 0) r.Skip(); else children[index].Read(ref r, flat, offset + offsets[index], scalar, o);
|
||||
if (index < 0)
|
||||
{
|
||||
if (o.UnmappedMemberHandling == JsonUnmappedMemberHandling.Disallow)
|
||||
throw new JsonException("The JSON object has a property that is not a component.");
|
||||
r.Skip();
|
||||
continue;
|
||||
}
|
||||
|
||||
var slice = flat.Slice(offsets[index], children[index].Count);
|
||||
var bit = 1 << index;
|
||||
if ((seen & bit) != 0)
|
||||
{
|
||||
if (!DuplicatesAllowed(o)) throw new JsonException($"The property '{names[index]}' appears more than once.");
|
||||
defaults.Slice(offsets[index], slice.Length).CopyTo(slice);
|
||||
}
|
||||
|
||||
seen |= bit;
|
||||
children[index].Read(ref r, slice, defaults.Slice(offsets[index], slice.Length), scalar, o);
|
||||
}
|
||||
}
|
||||
|
||||
private int IndexOf(ref Utf8JsonReader r, bool ignoreCase)
|
||||
{
|
||||
if (!ignoreCase)
|
||||
{
|
||||
for (var i = 0; i < utf8Names.Length; i++)
|
||||
if (r.ValueTextEquals(utf8Names[i])) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
var name = r.GetString()!;
|
||||
for (var i = 0; i < names.Length; i++)
|
||||
if (string.Equals(names[i], name, StringComparison.OrdinalIgnoreCase)) return i;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Rows<S> : Node<S>
|
||||
private sealed class Rows<S> : Node<S> where S : unmanaged
|
||||
{
|
||||
private readonly Node<S> row;
|
||||
private readonly int rows;
|
||||
|
|
@ -358,81 +362,98 @@ public static class CultMathJson
|
|||
Count = rows * row.Count;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar<S> scalar, JsonSerializerOptions o)
|
||||
public override void Write(Utf8JsonWriter w, ReadOnlySpan<S> flat, Scalar<S> scalar, JsonSerializerOptions o)
|
||||
{
|
||||
w.WriteStartArray();
|
||||
for (var i = 0; i < rows; i++) row.Write(w, flat, offset + (i * row.Count), scalar, o);
|
||||
for (var i = 0; i < rows; i++) row.Write(w, flat.Slice(i * row.Count, row.Count), scalar, o);
|
||||
w.WriteEndArray();
|
||||
}
|
||||
|
||||
public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar<S> scalar, JsonSerializerOptions o)
|
||||
public override void Read(ref Utf8JsonReader r, scoped Span<S> flat, scoped ReadOnlySpan<S> defaults, Scalar<S> scalar, JsonSerializerOptions o)
|
||||
{
|
||||
if (r.TokenType != JsonTokenType.StartArray) throw new JsonException($"Expected a JSON array of {rows} rows, found {r.TokenType}.");
|
||||
for (var i = 0; i < rows; i++)
|
||||
{
|
||||
if (!r.Read() || r.TokenType == JsonTokenType.EndArray) throw new JsonException($"Expected {rows} rows.");
|
||||
row.Read(ref r, flat, offset + (i * row.Count), scalar, o);
|
||||
row.Read(ref r, flat.Slice(i * row.Count, row.Count), defaults.Slice(i * row.Count, row.Count), scalar, o);
|
||||
}
|
||||
|
||||
if (!r.Read() || r.TokenType != JsonTokenType.EndArray) throw new JsonException($"Expected {rows} rows.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Shape<T, S> : JsonConverter<T>
|
||||
private delegate T Make<T, S>(ReadOnlySpan<S> components) where S : unmanaged;
|
||||
private delegate void Flatten<T, S>(T value, Span<S> components) where S : unmanaged;
|
||||
|
||||
private sealed class Shape<T, S> : JsonConverter<T> where S : unmanaged
|
||||
{
|
||||
private readonly Scalar<S> scalar;
|
||||
private readonly Node<S> root;
|
||||
private readonly Func<S[], T> make;
|
||||
private readonly Action<T, S[]> flatten;
|
||||
private readonly S[] defaults;
|
||||
private readonly Make<T, S> make;
|
||||
private readonly Flatten<T, S> flatten;
|
||||
|
||||
public Shape(Scalar<S> scalar, Node<S> root, Func<S[], T> make, Action<T, S[]> flatten)
|
||||
public Shape(Scalar<S> scalar, Node<S> root, Make<T, S> make, Flatten<T, S> flatten, S[]? initial)
|
||||
{
|
||||
this.scalar = scalar;
|
||||
this.root = root;
|
||||
this.make = make;
|
||||
this.flatten = flatten;
|
||||
defaults = initial ?? new S[root.Count];
|
||||
}
|
||||
|
||||
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var flat = scalar.Defaults(root.Count);
|
||||
root.Read(ref reader, flat, 0, scalar, options);
|
||||
Span<S> flat = stackalloc S[root.Count];
|
||||
defaults.CopyTo(flat);
|
||||
root.Read(ref reader, flat, defaults, scalar, options);
|
||||
return make(flat);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
|
||||
{
|
||||
var flat = new S[root.Count];
|
||||
Span<S> flat = stackalloc S[root.Count];
|
||||
flatten(value, flat);
|
||||
root.Write(writer, flat, 0, scalar, options);
|
||||
root.Write(writer, flat, scalar, options);
|
||||
}
|
||||
|
||||
public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var parts = (reader.GetString() ?? throw new JsonException("A dictionary key cannot be null.")).Split(',');
|
||||
var text = reader.GetString() ?? throw new JsonException("A dictionary key cannot be null.");
|
||||
var parts = text.Split(',');
|
||||
if (parts.Length != root.Count) throw new JsonException($"A {typeof(T).Name} key has {root.Count} comma-separated components.");
|
||||
var flat = new S[root.Count];
|
||||
for (var i = 0; i < flat.Length; i++) flat[i] = scalar.Parse(parts[i], options);
|
||||
return make(flat);
|
||||
Span<S> flat = stackalloc S[root.Count];
|
||||
for (var i = 0; i < parts.Length; i++) flat[i] = scalar.ParseKey(parts[i], options);
|
||||
var value = make(flat);
|
||||
if (Spell(flat) != text) throw new JsonException($"'{text}' is not the canonical spelling of a {typeof(T).Name} key.");
|
||||
return value;
|
||||
}
|
||||
|
||||
public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
|
||||
{
|
||||
var flat = new S[root.Count];
|
||||
Span<S> flat = stackalloc S[root.Count];
|
||||
flatten(value, flat);
|
||||
writer.WritePropertyName(string.Join(",", Array.ConvertAll(flat, c => scalar.Text(c, options))));
|
||||
for (var i = 0; i < flat.Length; i++) scalar.Text(flat[i], options);
|
||||
writer.WritePropertyName(Spell(flat));
|
||||
}
|
||||
|
||||
private string Spell(ReadOnlySpan<S> flat)
|
||||
{
|
||||
var parts = new string[flat.Length];
|
||||
for (var i = 0; i < parts.Length; i++) parts[i] = scalar.Spelling(flat[i]);
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
}
|
||||
|
||||
private static Node<S> Val<S>() => new Leaf<S>();
|
||||
private static Node<S> Val<S>() where S : unmanaged => new Leaf<S>();
|
||||
|
||||
private static Node<S> Xy<S>() => new Fields<S>(("x", Val<S>()), ("y", Val<S>()));
|
||||
private static Node<S> Xyz<S>() => new Fields<S>(("x", Val<S>()), ("y", Val<S>()), ("z", Val<S>()));
|
||||
private static Node<S> Xyzw<S>() => new Fields<S>(("x", Val<S>()), ("y", Val<S>()), ("z", Val<S>()), ("w", Val<S>()));
|
||||
private static Node<S> Xy<S>() where S : unmanaged => new Fields<S>(("x", Val<S>()), ("y", Val<S>()));
|
||||
private static Node<S> Xyz<S>() where S : unmanaged => new Fields<S>(("x", Val<S>()), ("y", Val<S>()), ("z", Val<S>()));
|
||||
private static Node<S> Xyzw<S>() where S : unmanaged => new Fields<S>(("x", Val<S>()), ("y", Val<S>()), ("z", Val<S>()), ("w", Val<S>()));
|
||||
|
||||
private static KeyValuePair<Type, JsonConverter> Add<T, S>(
|
||||
Scalar<S> scalar, Node<S> root, Func<S[], T> make, Action<T, S[]> flatten) =>
|
||||
new(typeof(T), new Shape<T, S>(scalar, root, make, flatten));
|
||||
Scalar<S> scalar, Node<S> root, Make<T, S> make, Flatten<T, S> flatten, S[]? initial = null) where S : unmanaged =>
|
||||
new(typeof(T), new Shape<T, S>(scalar, root, make, flatten, initial));
|
||||
|
||||
private static readonly Dictionary<Type, JsonConverter> Converters = Build();
|
||||
|
||||
|
|
@ -455,7 +476,8 @@ public static class CultMathJson
|
|||
Add<bool4, bool>(BoolScalar.Instance, Xyzw<bool>(), a => new bool4(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; a[3] = v.w; }),
|
||||
Add<Color32, byte>(ByteScalar.Instance,
|
||||
new Fields<byte>(("r", Val<byte>()), ("g", Val<byte>()), ("b", Val<byte>()), ("a", Val<byte>())),
|
||||
a => new Color32(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.r; a[1] = v.g; a[2] = v.b; a[3] = v.a; }),
|
||||
a => new Color32(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.r; a[1] = v.g; a[2] = v.b; a[3] = v.a; },
|
||||
initial: new byte[] { 0, 0, 0, 255 }),
|
||||
Add<Random, uint>(UIntScalar.Instance, new Fields<uint>(("state", Val<uint>())),
|
||||
a => new Random { state = a[0] }, (v, a) => a[0] = v.state),
|
||||
|
||||
|
|
|
|||
|
|
@ -65,14 +65,22 @@ in the default chain) and one JSON shape (`CultMathJson`, added to a consumer's
|
|||
options with `new JsonSerializerOptions().AddCultMathConverters()`; CultLib owns
|
||||
no JSON options of its own). MessagePack decoding skips extra elements, leaves
|
||||
missing ones at zero (`Color32` alpha at 255) and refuses nil. JSON reading
|
||||
skips unknown properties, leaves missing ones at zero, honours
|
||||
`PropertyNameCaseInsensitive`, and throws `JsonException` for a wrong token or an
|
||||
out-of-range number (a float beyond `float.MaxValue` is refused, not read as
|
||||
infinity). Numbers follow `JsonSerializerOptions.NumberHandling`: NaN and the
|
||||
infinities need `AllowNamedFloatingPointLiterals` (`"NaN"`, `"Infinity"`,
|
||||
`"-Infinity"`). Every type is also a dictionary key: its flattened components
|
||||
joined by commas, invariant culture (`"1,2"`, `"true,false"`, matrices row by
|
||||
row, `rect` as `"minX,minY,maxX,maxY"`). A consumer resolver or converter
|
||||
skips unknown properties (`UnmappedMemberHandling.Disallow` refuses them), leaves
|
||||
missing ones at zero, honours `PropertyNameCaseInsensitive`, lets a repeated
|
||||
property replace the whole earlier value (refused when `AllowDuplicateProperties`
|
||||
is false), and throws `JsonException` for a wrong token or an out-of-range number.
|
||||
Numbers follow `JsonSerializerOptions.NumberHandling` as System.Text.Json's own
|
||||
float does: `"NaN"`, `"Infinity"` and `"-Infinity"` are written under
|
||||
`AllowNamedFloatingPointLiterals` or `WriteAsString` and read under
|
||||
`AllowNamedFloatingPointLiterals` or `AllowReadingFromString`; quoted numbers read
|
||||
under `AllowReadingFromString` with no surrounding whitespace.
|
||||
Every type is also a dictionary key: its flattened components joined by commas,
|
||||
invariant culture (`"1,2"`, `"true,false"`, matrices row by row, `rect` as
|
||||
`"minX,minY,maxX,maxY"`), and only that exact spelling reads back. Two intended
|
||||
divergences from System.Text.Json: a bare number beyond a component's range
|
||||
(`1e40` as a float) is refused where System.Text.Json reads infinity, and a
|
||||
refusal on write is a `JsonException` where System.Text.Json throws
|
||||
`ArgumentException`. A consumer resolver or converter
|
||||
registered ahead of these wins.
|
||||
|
||||
| Type | MessagePack | JSON |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
|
@ -225,6 +226,190 @@ namespace GameCult.Caching.Tests
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue