Rebuild the CultMath JSON converters on a streaming reader with STJ number semantics
One Scalar plus Node description per type now drives reading, writing and the dictionary-key spelling, replacing the per-value JsonDocument parse. NaN and the infinities follow JsonNumberHandling (named literals or JsonException), floats beyond float.MaxValue are refused instead of becoming infinity, out-of-range components throw JsonException instead of FormatException, property matching honours PropertyNameCaseInsensitive, and every type works as a dictionary key spelled as comma-joined components. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
9b5597e89c
commit
817c2627b8
3 changed files with 562 additions and 78 deletions
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using CultMath;
|
||||
|
|
@ -12,8 +13,26 @@ namespace GameCult.Caching.MessagePack;
|
|||
/// <see cref="CultMathResolver"/>. Vectors, quaternions and colors are objects keyed by their
|
||||
/// field names (<c>{"x":1,"y":2}</c>), matrices are arrays of row objects, <c>rect</c> is
|
||||
/// <c>{"min":..,"max":..}</c>, and <c>CultCellular</c> is <c>{"nearest":..,"edge":..,"id":..}</c>.
|
||||
/// 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 value that is not an object, or a matrix that is not an array of exactly its row count, is refused.
|
||||
/// 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"/>.
|
||||
/// </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.
|
||||
/// </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>).
|
||||
/// </para>
|
||||
/// Register with <see cref="AddCultMathConverters"/>; JSON options are the consumer's own.
|
||||
/// </summary>
|
||||
public static class CultMathJson
|
||||
|
|
@ -29,111 +48,443 @@ public static class CultMathJson
|
|||
return options;
|
||||
}
|
||||
|
||||
private sealed class ObjectShape<T> : JsonConverter<T>
|
||||
{
|
||||
private readonly Action<Utf8JsonWriter, T> write;
|
||||
private readonly Func<JsonElement, T> read;
|
||||
private static bool Named(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.AllowNamedFloatingPointLiterals) != 0;
|
||||
private static bool FromString(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.AllowReadingFromString) != 0;
|
||||
private static bool AsString(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.WriteAsString) != 0;
|
||||
|
||||
public ObjectShape(Action<Utf8JsonWriter, T> write, Func<JsonElement, T> read)
|
||||
private static string Invalid(string what, string got) => $"{what} cannot be read from '{got}'.";
|
||||
|
||||
// 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>
|
||||
{
|
||||
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);
|
||||
public abstract S Parse(string text, JsonSerializerOptions o);
|
||||
}
|
||||
|
||||
private sealed class FloatScalar : Scalar<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)
|
||||
{
|
||||
this.write = write;
|
||||
this.read = read;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DoubleScalar : Scalar<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));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class IntScalar : Scalar<int>
|
||||
{
|
||||
public static readonly IntScalar Instance = new();
|
||||
|
||||
public override void Write(Utf8JsonWriter w, int v, JsonSerializerOptions o)
|
||||
{
|
||||
if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v);
|
||||
}
|
||||
|
||||
public override int 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.");
|
||||
return v;
|
||||
}
|
||||
|
||||
if (r.TokenType == JsonTokenType.String && FromString(o)) return Parse(r.GetString()!, o);
|
||||
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));
|
||||
}
|
||||
|
||||
private sealed class UIntScalar : Scalar<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));
|
||||
}
|
||||
|
||||
private sealed class ByteScalar : Scalar<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));
|
||||
}
|
||||
|
||||
private sealed class BoolScalar : Scalar<bool>
|
||||
{
|
||||
public static readonly BoolScalar Instance = new();
|
||||
|
||||
public override void Write(Utf8JsonWriter w, bool v, JsonSerializerOptions o) => w.WriteBooleanValue(v);
|
||||
|
||||
public override bool Read(ref Utf8JsonReader r, JsonSerializerOptions o) =>
|
||||
r.TokenType == JsonTokenType.True ? true
|
||||
: 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 bool Parse(string text, JsonSerializerOptions o) =>
|
||||
text == "true" ? true : text == "false" ? false : throw new JsonException(Invalid("A bool", text));
|
||||
}
|
||||
|
||||
// The JSON structure over a flat component list: leaves consume one component each, in order.
|
||||
private abstract class Node<S>
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
private sealed class Leaf<S> : Node<S>
|
||||
{
|
||||
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 Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar<S> scalar, JsonSerializerOptions o) => flat[offset] = scalar.Read(ref r, o);
|
||||
}
|
||||
|
||||
private sealed class Fields<S> : Node<S>
|
||||
{
|
||||
private readonly string[] names;
|
||||
private readonly Node<S>[] children;
|
||||
private readonly int[] offsets;
|
||||
|
||||
public Fields(params (string Name, Node<S> Child)[] fields)
|
||||
{
|
||||
names = new string[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;
|
||||
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)
|
||||
{
|
||||
w.WriteStartObject();
|
||||
for (var i = 0; i < names.Length; i++)
|
||||
{
|
||||
w.WritePropertyName(names[i]);
|
||||
children[i].Write(w, flat, offset + offsets[i], scalar, o);
|
||||
}
|
||||
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
public override void Read(ref Utf8JsonReader r, S[] flat, int offset, 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;
|
||||
while (r.Read() && r.TokenType == JsonTokenType.PropertyName)
|
||||
{
|
||||
var name = r.GetString()!;
|
||||
var index = Array.FindIndex(names, n => string.Equals(n, name, comparison));
|
||||
r.Read();
|
||||
if (index < 0) r.Skip(); else children[index].Read(ref r, flat, offset + offsets[index], scalar, o);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Rows<S> : Node<S>
|
||||
{
|
||||
private readonly Node<S> row;
|
||||
private readonly int rows;
|
||||
|
||||
public Rows(int rows, Node<S> row)
|
||||
{
|
||||
this.rows = rows;
|
||||
this.row = row;
|
||||
Count = rows * row.Count;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar<S> scalar, JsonSerializerOptions o)
|
||||
{
|
||||
w.WriteStartArray();
|
||||
for (var i = 0; i < rows; i++) row.Write(w, flat, offset + (i * row.Count), scalar, o);
|
||||
w.WriteEndArray();
|
||||
}
|
||||
|
||||
public override void Read(ref Utf8JsonReader r, S[] flat, int offset, 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);
|
||||
}
|
||||
|
||||
if (!r.Read() || r.TokenType != JsonTokenType.EndArray) throw new JsonException($"Expected {rows} rows.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Shape<T, S> : JsonConverter<T>
|
||||
{
|
||||
private readonly Scalar<S> scalar;
|
||||
private readonly Node<S> root;
|
||||
private readonly Func<S[], T> make;
|
||||
private readonly Action<T, S[]> flatten;
|
||||
|
||||
public Shape(Scalar<S> scalar, Node<S> root, Func<S[], T> make, Action<T, S[]> flatten)
|
||||
{
|
||||
this.scalar = scalar;
|
||||
this.root = root;
|
||||
this.make = make;
|
||||
this.flatten = flatten;
|
||||
}
|
||||
|
||||
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
using var document = JsonDocument.ParseValue(ref reader);
|
||||
return read(document.RootElement);
|
||||
var flat = scalar.Defaults(root.Count);
|
||||
root.Read(ref reader, flat, 0, scalar, options);
|
||||
return make(flat);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => write(writer, value);
|
||||
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
|
||||
{
|
||||
var flat = new S[root.Count];
|
||||
flatten(value, flat);
|
||||
root.Write(writer, flat, 0, 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(',');
|
||||
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);
|
||||
}
|
||||
|
||||
public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
|
||||
{
|
||||
var flat = new S[root.Count];
|
||||
flatten(value, flat);
|
||||
writer.WritePropertyName(string.Join(",", Array.ConvertAll(flat, c => scalar.Text(c, options))));
|
||||
}
|
||||
}
|
||||
|
||||
private static KeyValuePair<Type, JsonConverter> Shape<T>(Action<Utf8JsonWriter, T> write, Func<JsonElement, T> read) =>
|
||||
new(typeof(T), new ObjectShape<T>(write, read));
|
||||
private static Node<S> Val<S>() => new Leaf<S>();
|
||||
|
||||
private static JsonElement Obj(JsonElement e)
|
||||
{
|
||||
if (e.ValueKind != JsonValueKind.Object) throw new JsonException("Expected a JSON object.");
|
||||
return e;
|
||||
}
|
||||
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 JsonElement Rows(JsonElement e, int count)
|
||||
{
|
||||
if (e.ValueKind != JsonValueKind.Array || e.GetArrayLength() != count)
|
||||
throw new JsonException($"Expected a JSON array of {count} rows.");
|
||||
return e;
|
||||
}
|
||||
|
||||
private static bool Has(JsonElement e, string name, out JsonElement value) => Obj(e).TryGetProperty(name, out value);
|
||||
private static float F(JsonElement e, string name) => Has(e, name, out var v) ? v.GetSingle() : 0f;
|
||||
private static double D(JsonElement e, string name) => Has(e, name, out var v) ? v.GetDouble() : 0d;
|
||||
private static int I(JsonElement e, string name) => Has(e, name, out var v) ? v.GetInt32() : 0;
|
||||
private static bool B(JsonElement e, string name) => Has(e, name, out var v) && v.GetBoolean();
|
||||
private static JsonElement Member(JsonElement e, string name) =>
|
||||
Has(e, name, out var v) ? v : Empty;
|
||||
private static readonly JsonElement Empty = JsonDocument.Parse("{}").RootElement;
|
||||
|
||||
private static void PutFloat2(Utf8JsonWriter w, float2 v) { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteEndObject(); }
|
||||
private static void PutFloat3(Utf8JsonWriter w, float3 v) { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteEndObject(); }
|
||||
private static void PutFloat4(Utf8JsonWriter w, float4 v) { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteNumber("w", v.w); w.WriteEndObject(); }
|
||||
private static float2 GetFloat2(JsonElement e) => new(F(e, "x"), F(e, "y"));
|
||||
private static float3 GetFloat3(JsonElement e) => new(F(e, "x"), F(e, "y"), F(e, "z"));
|
||||
private static float4 GetFloat4(JsonElement e) => new(F(e, "x"), F(e, "y"), F(e, "z"), F(e, "w"));
|
||||
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));
|
||||
|
||||
private static readonly Dictionary<Type, JsonConverter> Converters = Build();
|
||||
|
||||
private static Dictionary<Type, JsonConverter> Build()
|
||||
{
|
||||
var f = FloatScalar.Instance;
|
||||
var shapes = new[]
|
||||
{
|
||||
Shape<float2>(PutFloat2, GetFloat2),
|
||||
Shape<float3>(PutFloat3, GetFloat3),
|
||||
Shape<float4>(PutFloat4, GetFloat4),
|
||||
Shape<double2>((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteEndObject(); },
|
||||
e => new double2(D(e, "x"), D(e, "y"))),
|
||||
Shape<double3>((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteEndObject(); },
|
||||
e => new double3(D(e, "x"), D(e, "y"), D(e, "z"))),
|
||||
Shape<int2>((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteEndObject(); },
|
||||
e => new int2(I(e, "x"), I(e, "y"))),
|
||||
Shape<int3>((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteEndObject(); },
|
||||
e => new int3(I(e, "x"), I(e, "y"), I(e, "z"))),
|
||||
Shape<int4>((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteNumber("w", v.w); w.WriteEndObject(); },
|
||||
e => new int4(I(e, "x"), I(e, "y"), I(e, "z"), I(e, "w"))),
|
||||
Shape<bool2>((w, v) => { w.WriteStartObject(); w.WriteBoolean("x", v.x); w.WriteBoolean("y", v.y); w.WriteEndObject(); },
|
||||
e => new bool2(B(e, "x"), B(e, "y"))),
|
||||
Shape<bool3>((w, v) => { w.WriteStartObject(); w.WriteBoolean("x", v.x); w.WriteBoolean("y", v.y); w.WriteBoolean("z", v.z); w.WriteEndObject(); },
|
||||
e => new bool3(B(e, "x"), B(e, "y"), B(e, "z"))),
|
||||
Shape<bool4>((w, v) => { w.WriteStartObject(); w.WriteBoolean("x", v.x); w.WriteBoolean("y", v.y); w.WriteBoolean("z", v.z); w.WriteBoolean("w", v.w); w.WriteEndObject(); },
|
||||
e => new bool4(B(e, "x"), B(e, "y"), B(e, "z"), B(e, "w"))),
|
||||
Shape<quaternion>((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteNumber("w", v.w); w.WriteEndObject(); },
|
||||
e => new quaternion(F(e, "x"), F(e, "y"), F(e, "z"), F(e, "w"))),
|
||||
Shape<Color32>((w, v) => { w.WriteStartObject(); w.WriteNumber("r", v.r); w.WriteNumber("g", v.g); w.WriteNumber("b", v.b); w.WriteNumber("a", v.a); w.WriteEndObject(); },
|
||||
e => new Color32(
|
||||
Has(e, "r", out var r) ? r.GetByte() : (byte)0,
|
||||
Has(e, "g", out var g) ? g.GetByte() : (byte)0,
|
||||
Has(e, "b", out var b) ? b.GetByte() : (byte)0,
|
||||
Has(e, "a", out var a) ? a.GetByte() : (byte)255)),
|
||||
Shape<Random>((w, v) => { w.WriteStartObject(); w.WriteNumber("state", v.state); w.WriteEndObject(); },
|
||||
e => new Random { state = Has(e, "state", out var s) ? s.GetUInt32() : 0u }),
|
||||
Add<float2, float>(f, Xy<float>(), a => new float2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }),
|
||||
Add<float3, float>(f, Xyz<float>(), a => new float3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }),
|
||||
Add<float4, float>(f, Xyzw<float>(), a => new float4(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<quaternion, float>(f, Xyzw<float>(), a => new quaternion(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<double2, double>(DoubleScalar.Instance, Xy<double>(), a => new double2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }),
|
||||
Add<double3, double>(DoubleScalar.Instance, Xyz<double>(), a => new double3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }),
|
||||
Add<int2, int>(IntScalar.Instance, Xy<int>(), a => new int2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }),
|
||||
Add<int3, int>(IntScalar.Instance, Xyz<int>(), a => new int3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }),
|
||||
Add<int4, int>(IntScalar.Instance, Xyzw<int>(), a => new int4(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<bool2, bool>(BoolScalar.Instance, Xy<bool>(), a => new bool2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }),
|
||||
Add<bool3, bool>(BoolScalar.Instance, Xyz<bool>(), a => new bool3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }),
|
||||
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; }),
|
||||
Add<Random, uint>(UIntScalar.Instance, new Fields<uint>(("state", Val<uint>())),
|
||||
a => new Random { state = a[0] }, (v, a) => a[0] = v.state),
|
||||
|
||||
// Compositions: matrices are arrays of row objects, rect is {min, max}, CultCellular is {nearest, edge, id}.
|
||||
Shape<float2x2>((w, v) => { w.WriteStartArray(); PutFloat2(w, v[0]); PutFloat2(w, v[1]); w.WriteEndArray(); },
|
||||
e =>
|
||||
Add<float2x2, float>(f, new Rows<float>(2, Xy<float>()),
|
||||
a => new float2x2(a[0], a[1], a[2], a[3]),
|
||||
(v, a) => { a[0] = v[0].x; a[1] = v[0].y; a[2] = v[1].x; a[3] = v[1].y; }),
|
||||
Add<float3x3, float>(f, new Rows<float>(3, Xyz<float>()),
|
||||
a => new float3x3(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8]),
|
||||
(v, a) =>
|
||||
{
|
||||
var rows = Rows(e, 2);
|
||||
return new float2x2(GetFloat2(rows[0]), GetFloat2(rows[1]));
|
||||
for (var row = 0; row < 3; row++)
|
||||
{
|
||||
a[row * 3] = v[row].x;
|
||||
a[(row * 3) + 1] = v[row].y;
|
||||
a[(row * 3) + 2] = v[row].z;
|
||||
}
|
||||
}),
|
||||
Shape<float3x3>((w, v) => { w.WriteStartArray(); PutFloat3(w, v[0]); PutFloat3(w, v[1]); PutFloat3(w, v[2]); w.WriteEndArray(); },
|
||||
e =>
|
||||
Add<rect, float>(f, new Fields<float>(("min", Xy<float>()), ("max", Xy<float>())),
|
||||
a => new rect(a[0], a[1], a[2], a[3]),
|
||||
(v, a) => { a[0] = v.min.x; a[1] = v.min.y; a[2] = v.max.x; a[3] = v.max.y; }),
|
||||
Add<CultCellular, float>(f, new Fields<float>(("nearest", Xyzw<float>()), ("edge", Xyzw<float>()), ("id", Val<float>())),
|
||||
a => new CultCellular(new float4(a[0], a[1], a[2], a[3]), new float4(a[4], a[5], a[6], a[7]), a[8]),
|
||||
(v, a) =>
|
||||
{
|
||||
var rows = Rows(e, 3);
|
||||
return new float3x3(GetFloat3(rows[0]), GetFloat3(rows[1]), GetFloat3(rows[2]));
|
||||
a[0] = v.nearest.x; a[1] = v.nearest.y; a[2] = v.nearest.z; a[3] = v.nearest.w;
|
||||
a[4] = v.edge.x; a[5] = v.edge.y; a[6] = v.edge.z; a[7] = v.edge.w;
|
||||
a[8] = v.id;
|
||||
}),
|
||||
Shape<rect>((w, v) => { w.WriteStartObject(); w.WritePropertyName("min"); PutFloat2(w, v.min); w.WritePropertyName("max"); PutFloat2(w, v.max); w.WriteEndObject(); },
|
||||
e => new rect(GetFloat2(Member(e, "min")), GetFloat2(Member(e, "max")))),
|
||||
Shape<CultCellular>((w, v) => { w.WriteStartObject(); w.WritePropertyName("nearest"); PutFloat4(w, v.nearest); w.WritePropertyName("edge"); PutFloat4(w, v.edge); w.WriteNumber("id", v.id); w.WriteEndObject(); },
|
||||
e => new CultCellular(GetFloat4(Member(e, "nearest")), GetFloat4(Member(e, "edge")), F(e, "id"))),
|
||||
};
|
||||
|
||||
var map = new Dictionary<Type, JsonConverter>();
|
||||
|
|
|
|||
|
|
@ -65,8 +65,15 @@ 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 and leaves missing ones at zero. A consumer resolver or
|
||||
converter registered ahead of these wins.
|
||||
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
|
||||
registered ahead of these wins.
|
||||
|
||||
| Type | MessagePack | JSON |
|
||||
| --- | --- | --- |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using CultMath;
|
||||
using FluentAssertions;
|
||||
using GameCult.Caching.MessagePack;
|
||||
|
|
@ -99,6 +102,129 @@ namespace GameCult.Caching.Tests
|
|||
}
|
||||
}
|
||||
|
||||
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 static void RoundTrip<T>(T value) => Parse<T>(Json(value)).Should().Be(value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue