using System; using System.Collections.Generic; using System.Linq; using CultMath.Expr; using FluentAssertions; using GameCult.Caching.MessagePack; using MessagePack; using NUnit.Framework; namespace GameCult.Caching.Tests { /// /// The MessagePack shape of CultMath.Expr.ExprGraph (CultMathExprResolver, in the default chain): a /// consumer record with a graph field needs no registration, a graph encodes to canonical bytes and decodes to a /// graph that evaluates bit for bit the same, and decode runs the one validator so a broken encoding is refused /// whole, naming its rule and never echoing what it was given. /// public sealed class CultMathExprSerializationTests { private static readonly MessagePackSerializerOptions Options = CultDocumentMessagePackSerialization.Options; private const string Canary = "CANARY_NAME"; [CultDocument("tests.expr_holder", "tests.expr_holder.v1")] [MessagePackObject(AllowPrivate = true)] [CultGlobal] internal sealed class ExprHolder { [Key(0)] public ExprGraph Graph = default!; [Key(1)] public float Scale; } private static ExprGraph Sample() { var b = new ExprBuilder(); var x = b.Input("x"); var z = b.Input("z"); var k = b.Param("k", 1.5f); var cell = b.Call("cellular3", x, z, k); var field = b.Call("snoise2", b.Call("mul", x, k), z); b.Output("o", b.Call("add", field, b.Call("sub", b.Pick(cell, 7), b.Const(-0f)))); return b.Build(); } private static byte[] Encode(ExprGraph graph) => MessagePackSerializer.Serialize(graph, Options); private static ExprGraph Decode(byte[] bytes) => MessagePackSerializer.Deserialize(bytes, Options); private static float[] Evaluate(ExprGraph graph, float[] x, float[] z) { var outputs = new float[x.Length * graph.OutputNames.Count]; ExprEvaluator.Evaluate(graph, ExprBindings.Bind(graph), x.Concat(z).ToArray(), outputs); return outputs; } [Test] public void ConsumerRecordRoundTrips() { var graph = Sample(); var record = new ExprHolder { Graph = graph, Scale = 2f }; var bytes = MessagePackSerializer.Serialize(record, Options); var decoded = MessagePackSerializer.Deserialize(bytes, Options); decoded.Scale.Should().Be(2f); Encode(decoded.Graph).Should().Equal(Encode(graph)); var rng = new Random(11); var x = Enumerable.Range(0, 256).Select(_ => (float)(rng.NextDouble() * 20 - 10)).ToArray(); var z = Enumerable.Range(0, 256).Select(_ => (float)(rng.NextDouble() * 20 - 10)).ToArray(); var expected = Evaluate(graph, x, z); var actual = Evaluate(decoded.Graph, x, z); actual.Select(BitConverter.SingleToInt32Bits).Should().Equal(expected.Select(BitConverter.SingleToInt32Bits)); } [Test] public void BuildIsCanonical_TheSameCallsEncodeToTheSameBytes() { Encode(Sample()).Should().Equal(Encode(Sample())); var b = new ExprBuilder(); var x = b.Input("x"); var k = b.Param("k", 1.5f); b.Output("o", b.Call("add", x, k)); MessagePackSerializer.ConvertToJson(Encode(b.Build()), Options).Should().Be( "[1,[\"x\"],[[\"k\",1069547520]],[[\"o\",2]],[[\"input\",[],0],[\"param\",[],0],[\"add\",[0,1],0]]]"); var dead = new ExprBuilder(); var dx = dead.Input("x"); var dk = dead.Param("k", 1.5f); dead.Call("sin", dx); dead.Output("o", dead.Call("add", dx, dk)); Encode(dead.Build()).Should().Equal(Encode(b.Build())); } [Test] public void ConstantsKeepTheirBits() { const int nanBits = 0x7FC12345; var b = new ExprBuilder(); b.Output("a", b.Const(-0f)); b.Output("b", b.Const(BitConverter.Int32BitsToSingle(nanBits))); b.Output("c", b.Const(BitConverter.Int32BitsToSingle(1))); b.Output("d", b.Param("p", BitConverter.Int32BitsToSingle(nanBits))); var graph = b.Build(); var decoded = Decode(Encode(graph)); Encode(decoded).Should().Equal(Encode(graph)); decoded.Nodes.Where(n => n.Kind == ExprNodeKind.Const).Select(n => n.Payload) .Should().Equal(int.MinValue, nanBits, 1); decoded.ParamDefaultBits.Should().Equal(nanBits); var outputs = new float[4]; ExprEvaluator.Evaluate(decoded, ExprBindings.Bind(decoded), ReadOnlySpan.Empty, outputs); outputs.Select(BitConverter.SingleToInt32Bits).Should().Equal(int.MinValue, nanBits, 1, nanBits); } private static object[] Input() => new object[] { "input", new object[0], 0 }; private static void Put(ref MessagePackWriter writer, object? value) { switch (value) { case null: writer.WriteNil(); break; case int i: writer.Write(i); break; case string s: writer.Write(s); break; case System.Collections.IEnumerable items: var all = items.Cast().ToArray(); writer.WriteArrayHeader(all.Length); foreach (var item in all) Put(ref writer, item); break; } } private static byte[] Bytes(object? value) { var buffer = new System.Buffers.ArrayBufferWriter(); var writer = new MessagePackWriter(buffer); Put(ref writer, value); writer.Flush(); return buffer.WrittenMemory.ToArray(); } private static byte[] Wire(object version, object[] inputs, object[] nodes, int outputNode) => Bytes(new object[] { version, inputs, new object[0], new object[] { new object[] { "o", outputNode } }, nodes }); private static void Refused(byte[] bytes, string rule, int node = -1) { var act = () => Decode(bytes); var thrown = act.Should().Throw().Which; var inner = thrown.InnerException.Should().BeOfType().Which; inner.Rule.Should().Be(rule); inner.NodeIndex.Should().Be(node); thrown.ToString().Should().NotContain(Canary); } [Test] public void DecodeRefusesInvalidGraphs() { var x = new object[] { "x" }; var cell = new object[] { "cellular3", new object[] { 0, 0, 0 }, 0 }; Decode(Wire(1, x, new[] { Input() }, 0)).Nodes.Should().HaveCount(1); Refused(Wire(1, x, new object[] { Input(), new object[] { Canary + "_op", new object[] { 0 }, 0 } }, 1), "unknown-op", 1); Refused(Wire(1, x, new object[] { Input(), new object[] { "add", new object[] { 0 }, 0 } }, 1), "arity", 1); Refused(Wire(1, x, new object[] { Input(), new object[] { "neg", new object[] { 2 }, 0 } }, 1), "operand-order", 1); Refused(Wire(1, x, new object[] { Input(), new object[] { "neg", new object[] { 1 }, 0 } }, 1), "operand-order", 1); Refused(Wire(1, x, new object[] { Input(), new object[] { "pick", new object[] { 0 }, 0 } }, 1), "pick-range", 1); Refused(Wire(1, x, new object[] { Input(), cell, new object[] { "pick", new object[] { 1 }, 9 } }, 2), "pick-range", 2); Refused(Wire(1, x, new object[] { Input(), cell, new object[] { "neg", new object[] { 1 }, 0 } }, 2), "bare-multi-result", 2); Refused(Wire(1, new object[] { "x", "x" }, new[] { Input() }, 0), "duplicate-port-name"); Refused(Wire(1, new object[] { Canary + "-bad" }, new[] { Input() }, 0), "port-name"); Refused(Wire(2, x, new[] { Input() }, 0), "version"); Refused(Wire(1, x, new[] { Input() }, 3), "output-node"); } [Test] public void DecodeRefusesNilAndWrongShapes() { Refused(Bytes(new object[] { 1, new object[0] }), "shape"); } [Test] public void ANilGraphIsNilLikeEveryOtherReferenceSlot() { MessagePackSerializer.Serialize(null, Options).Should().Equal(new byte[] { 0xc0 }); MessagePackSerializer.Deserialize(new byte[] { 0xc0 }, Options).Should().BeNull(); var bytes = MessagePackSerializer.Serialize(new ExprHolder { Graph = null!, Scale = 3f }, Options); var decoded = MessagePackSerializer.Deserialize(bytes, Options); decoded.Graph.Should().BeNull(); decoded.Scale.Should().Be(3f); } // [1, ["x"], [], [["o", 0]], [["input", [], 0]]] private static readonly byte[] Minimal = { 0x95, 0x01, 0x91, 0xa1, 0x78, 0x90, 0x91, 0x92, 0xa1, 0x6f, 0x00, 0x91, 0x93, 0xa5, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x90, 0x00, }; private static byte[] Replace(byte[] bytes, int at, int length, params byte[] with) => bytes.Take(at).Concat(with).Concat(bytes.Skip(at + length)).ToArray(); [Test] public void DecodeRefusesEveryEncodingButTheOneEncodeWrites() { Encode(Decode(Minimal)).Should().Equal(Minimal); Refused(Replace(Minimal, 1, 1, 0xd2, 0, 0, 0, 1), "non-canonical"); // version as int32 Refused(Replace(Minimal, 1, 1, 0xcc, 1), "non-canonical"); // version as uint8 Refused(Replace(Minimal, 3, 2, 0xd9, 1, 0x78), "non-canonical"); // "x" as str8 Refused(Replace(Minimal, 2, 1, 0xdc, 0, 1), "non-canonical"); // input list header as array16 Refused(Replace(Minimal, 5, 1, 0xdd, 0, 0, 0, 0), "non-canonical"); // empty param list as array32 Refused(Replace(Minimal, Minimal.Length - 1, 1, 0xcc, 0), "non-canonical"); // payload as uint8 Refused(Replace(Minimal, 10, 1, 0xd0, 0), "non-canonical"); // output node as int8 } [Test] public void DecodeRefusesNestedShapesAndNilNames() { var node = new object[] { "input", new object[0], 0 }; object[] Graph(object[]? param = null, object[]? output = null, object? node0 = null) => new object[] { 1, new object[] { "x" }, param is null ? new object[0] : new object[] { param }, new object[] { output ?? new object[] { "o", 0 } }, new object[] { node0 ?? node }, }; Decode(Bytes(Graph(param: new object[] { "k", 0 }, node0: new object[] { "param", new object[0], 0 }))) .ParamNames.Should().Equal("k"); Refused(Bytes(Graph(param: new object[] { "k", 0, 0 })), "shape"); Refused(Bytes(Graph(param: new object[] { "k" })), "shape"); Refused(Bytes(Graph(output: new object[] { "o", 0, 0 })), "shape"); Refused(Bytes(Graph(output: new object[] { "o" })), "shape"); Refused(Bytes(Graph(node0: new object[] { "input", new object[0], 0, 0 })), "shape"); Refused(Bytes(Graph(node0: new object[] { "input", new object[0] })), "shape"); Refused(Bytes(new object[] { 1, new object?[] { null }, new object[0], new object[0], new object[0] }), "shape"); Refused(Bytes(Graph(param: new object?[] { null, 0 })), "shape"); Refused(Bytes(Graph(output: new object?[] { null, 0 })), "shape"); Refused(Bytes(Graph(node0: new object?[] { null, new object[0], 0 })), "shape"); Refused(Bytes(new object[] { 1, new object[0], new object[0], new object[0], new object[0], 0 }), "shape"); } [Test] public void DepthIsReleasedAfterEachGraph() { var shallow = Options.WithSecurity(MessagePackSecurity.UntrustedData.WithMaximumObjectGraphDepth(4)); var graphs = Enumerable.Range(0, 40).Select(_ => Sample()).ToArray(); var bytes = MessagePackSerializer.Serialize(graphs, shallow); var decoded = MessagePackSerializer.Deserialize(bytes, shallow); decoded.Should().HaveCount(40); Encode(decoded[39]).Should().Equal(Encode(graphs[0])); var tooDeep = Options.WithSecurity(MessagePackSecurity.UntrustedData.WithMaximumObjectGraphDepth(1)); var act = () => MessagePackSerializer.Deserialize(bytes, tooDeep); act.Should().Throw(); } } }