diff --git a/tests/GameCult.Thing.Contract.Tests/Builders.cs b/tests/GameCult.Thing.Contract.Tests/Builders.cs new file mode 100644 index 0000000..9c03251 --- /dev/null +++ b/tests/GameCult.Thing.Contract.Tests/Builders.cs @@ -0,0 +1,297 @@ +using GameCult.Thing.Contract; + +namespace GameCult.Thing.Contract.Tests; + +/// +/// The values the goldens and the other tests share, ported from crates/thing-contract/tests/common/mod.rs and +/// tests/goldens.rs. Every value is built through the package's public constructors; no bytes are spelled here. +/// +internal static class Builders +{ + // A Bind's slots, held until a union arm takes them: the arms carry the slots flat, so there is no shared Bind arm. + internal sealed record BindParts(StateSource Source, MemberPath Member, Format? Format) + { + public Bind ToBind() => new(Source, Member, Format); + public TextSource Text() => new TextSourceBound(Source, Member, Format); + public NumberSource Number() => new NumberSourceBound(Source, Member, Format); + public BoolSource Bool() => new BoolSourceBound(Source, Member, Format); + public CollectionSource Collection() => new CollectionSourceMember(Source, Member, Format); + public ArgValue Arg() => new ArgValueBind(Source, Member, Format); + } + + public static TextSource LitText(string value) => new TextLiteral(value); + public static BoolSource LitBool(bool value) => new BoolLiteral(value); + public static NumberSource LitNumber(double value) => new NumberLiteral(value); + + public static MemberPath Names(params string[] names) => + new(names.Select(name => (MemberStep)new MemberName(name)).ToList()); + + public static BindParts RecordBind(MemberPath member, Format? format) => + new(new StateRef("provider.a", "gamecult.thing.test.v1", "row-1"), member, format); + + public static BindParts ItemBind(MemberPath member) => new(StateSource.Item, member, null); + + public static CommandRef Command(string id) => new CommandIdRef(id); + + public static NodeCommon Common(string id) => new(id, LitBool(true), null); + + public static Dimension Px(double value) => new DimensionPx(value); + + public static Size2 Size(Dimension width, Dimension height) => new(width, height); + + public static Edges AllEdges(Dimension all) => new(all, all, all, all); + + public static BoxStyle Style( + Display? display = null, + Direction? direction = null, + Wrap? wrap = null, + Position? position = null, + Overflow? overflow = null, + ItemAlign? alignItems = null, + ItemAlign? alignSelf = null, + ContentAlign? alignContent = null, + ContentAlign? justifyContent = null, + Dimension? basis = null, + List? gridColumns = null, + List? gridRows = null) => + new( + display ?? Display.Flex, + direction ?? Direction.Column, + wrap ?? Wrap.NoWrap, + Size(Px(4), Px(8)), + AllEdges(Px(2)), + AllEdges(Dimension.Auto), + Size(new DimensionPercent(100), Dimension.Content), + Size(Px(0), Px(0)), + Size(Dimension.Auto, Dimension.Auto), + position ?? Position.Relative, + AllEdges(Dimension.Auto), + overflow ?? Overflow.Visible, + alignItems ?? ItemAlign.Stretch, + alignSelf ?? ItemAlign.Auto, + alignContent ?? ContentAlign.Start, + justifyContent ?? ContentAlign.SpaceBetween, + 1.0, + 0.0, + basis ?? Dimension.Auto, + gridColumns ?? new List(), + gridRows ?? new List()); + + public static Paint PlainPaint() => new(null, null, 0.0); + + public static Node Boxed(string id, params Node[] children) => + new BoxNode(Common(id), Style(), PlainPaint(), Role.Group, children.ToList()); + + public static Node Text(string id, string content) => + new TextNode(Common(id), LitText(content), "body", true, null, false); + + public static CommandInvocation Invocation() => new( + "3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b", + "thing:surface:provider.a.main", + 7, + "go", + new RecordPayload("gamecult.thing.test.go.v1", new byte[] { 0x91, 0x01, 0xff, 0x00 }), + new List + { + new("name-input", new TextValue("Ada")), + new("level", new NumberValue(0.75)), + new("flag", new BoolValue(true)), + new("pick", new ChoiceValue("a")), + }, + "renderer.web", + "2026-01-01T00:00:00Z", + "delegate-1"); + + public static CommandInvocation InvocationNamed(string id) => new( + id, "thing:surface:provider.a.main", 7, "go", + new RecordPayload("gamecult.thing.test.go.v1", new byte[] { 0x91, 0x01, 0xff, 0x00 }), + new List(), "renderer.web", "2026-01-01T00:00:00Z", null); + + public static List BindAllFormats() + { + var number = new NumberFormat(2, true, "ms"); + var affix = new AffixFormat("$", "/s"); + var indexed = new MemberPath(new List { new MemberName("items"), new MemberIndex(3) }); + return new List + { + RecordBind(indexed, number), + RecordBind(Names("elapsed"), Format.Duration), + RecordBind(Names("at"), Format.Timestamp), + RecordBind(Names("price"), affix), + RecordBind(Names("plain"), null), + ItemBind(Names("name")), + }; + } + + public static SurfaceDocument FullSurface() + { + var binds = BindAllFormats(); + var boundText = binds[3].Text(); + var boundBool = binds[4].Bool(); + var boundNumber = binds[0].Number(); + + var painted = new BoxNode( + new NodeCommon("painted", new BoolSourceParam("show"), new TextSourceParam("label")), + Style( + display: Display.Grid, + direction: Direction.RowReverse, + wrap: Wrap.WrapReverse, + position: Position.Absolute, + overflow: Overflow.Scroll, + alignItems: ItemAlign.Baseline, + alignSelf: ItemAlign.Center, + alignContent: ContentAlign.SpaceAround, + justifyContent: ContentAlign.SpaceEvenly, + gridColumns: new List { new DimensionFr(1), Px(120) }, + gridRows: new List { Dimension.Content }), + new Paint(new ThemeToken("surface.raised"), new Border(new Rgba(1, 2, 3, 255), 1.5), 4.0), + Role.Region, + new List { Text("deep-text", "deep") }); + + var repeat = new RepeatNode( + Common("rows"), + new RecordsSource(null, "gamecult.thing.test.v1", "row-"), + Names("id"), + new TextNode(Common("row-name"), ItemBind(Names("name")).Text(), "body", false, 2, true)); + + var leaves = new List + { + new TextNode(Common("t-bound"), boundText, "label", true, null, false), + new ImageNode(Common("img"), "icons", "gear", ImageFit.Cover, LitText("a gear")), + new ButtonNode(Common("go"), new List { Text("go-label", "Go") }, Command("go"), boundBool), + new ToggleNode(Common("flag"), boundBool, new CommandRefParam("toggle")), + new SliderNode(Common("level"), 0.0, 1.0, 0.25, boundNumber, Command("set-level")), + new TextInputNode(Common("name-input"), true, InputKind.Number, LitText("name"), new TextSourceParam("initial"), Command("rename")), + new TextInputNode(Common("title-input"), false, InputKind.Text, LitText("title"), new TextSourceParam("initial"), Command("retitle")), + new SelectNode( + Common("pick"), + new LiteralOptions(new List { new("a", "Alpha") }), + binds[4].ToBind(), + Command("pick")), + new SelectNode( + Common("pick-bound"), + new BoundOptions(binds[0].Collection(), Names("id"), Names("name")), + binds[5].ToBind(), + Command("pick")), + repeat, + new OverlayNode(Common("menu"), new AnchorNode("go"), false, Command("close"), new List { Text("menu-item", "one") }), + new OverlayNode(Common("dialog"), Anchor.Viewport, true, Command("close"), new List()), + new PlotNode(Common("trend"), binds[1].ToBind(), PlotMark.Area, 0.0, null, null, 100.0), + new EmbedNode(Common("viewport"), "world.scene", "main", 640.0, 480.0), + new UseNode( + Common("kv"), + new CompositionRef("thing.std", "key-value", 1), + new List { new("key", new TextArg("k")) }), + }; + + var deep = Boxed("d4", painted); + foreach (var id in new[] { "d3", "d2", "d1" }) deep = Boxed(id, deep); + var children = new List { deep }; + children.AddRange(leaves); + children.Add(new PlotNode(Common("trend-line"), binds[2].ToBind(), PlotMark.Line, null, null, null, null)); + children.Add(new PlotNode(Common("trend-bar"), binds[2].ToBind(), PlotMark.Bar, null, null, null, null)); + var fits = new[] { ImageFit.Contain, ImageFit.Fill, ImageFit.Unscaled }; + for (var index = 0; index < fits.Length; index++) + children.Add(new ImageNode(Common($"img-fit-{index}"), "icons", "gear", fits[index], LitText(""))); + var roles = new[] { Role.List, Role.ListItem, Role.Heading, Role.Dialog, Role.Toolbar }; + for (var index = 0; index < roles.Length; index++) + { + var roleStyle = index == 0 + ? Style(direction: Direction.Row, alignContent: ContentAlign.Auto, justifyContent: ContentAlign.Stretch) + : Style(); + children.Add(new BoxNode(Common($"role-{index}"), roleStyle, PlainPaint(), roles[index], new List())); + } + + children.Add(new BoxNode( + Common("block"), + Style( + display: Display.Block, + direction: Direction.ColumnReverse, + wrap: Wrap.Wrapped, + overflow: Overflow.Clip, + alignContent: ContentAlign.Center, + justifyContent: ContentAlign.End, + alignItems: ItemAlign.End, + alignSelf: ItemAlign.Start, + basis: Dimension.Content), + PlainPaint(), + Role.Group, + new List())); + children.Add(new TextNode(Common("t-param"), new TextSourceParam("title"), "title", true, null, false)); + children.Add(new ToggleNode(new NodeCommon("literal-number", new BoolSourceParam("v"), null), LitBool(false), Command("toggle"))); + children.Add(new SliderNode(Common("slider-literal"), -1.0, 1.0, 0.5, LitNumber(0.5), Command("set-level"))); + children.Add(new SliderNode(Common("slider-param"), 0.0, 10.0, 1.0, new NumberSourceParam("n"), Command("set-level"))); + children.Add(new RepeatNode(Common("rows-param"), new CollectionSourceParam("rows"), Names("id"), Text("row-param", "r"))); + + return new SurfaceDocument( + "provider.a", + "main", + 7, + "All arms", + Boxed("root", children.ToArray()), + new List + { + new("go", "gamecult.thing.test.go.v1", new List { "name-input", "level" }, "Go now?"), + new("close", "gamecult.thing.test.close.v1", new List(), null), + }); + } + + public static CompositionDocument FullComposition() + { + var args = new List + { + new("text", new TextArg("t")), + new("number", new NumberArg(2.5)), + new("bool", new BoolArg(true)), + new("bind", RecordBind(Names("x"), null).Arg()), + new("collection", new CollectionArg(new CollectionSourceParam("rows"))), + new("command", new CommandArg(Command("go"))), + new("slot", new SlotArg(new List { Text("slot-text", "inside"), Boxed("slot-box") })), + }; + var innerUse = new UseNode(Common("inner"), new CompositionRef("thing.std", "card", 2), args); + return new CompositionDocument( + "provider.a", + "panel", + 3, + new List + { + new("title", ParamKind.Text), + new("count", ParamKind.Number), + new("on", ParamKind.Bool), + new("source", ParamKind.Bind), + new("rows", ParamKind.Collection), + new("act", ParamKind.Command), + new("body", ParamKind.Slot), + }, + Boxed("panel-root", Text("panel-title", "t"), innerUse)); + } + + public static List<(string Name, CommandResult Result)> Results(CommandInvocation invocation) => new() + { + ("result-pending", CommandResult.Pending(invocation, 1, "working")), + ("result-completed", CommandResult.Completed(invocation, 2, 8, true, null)), + ("result-completed-open-surface", CommandResult.Completed(invocation, 3, null, false, new OpenSurfaceEffect("thing:surface:provider.a.next"))), + ("result-completed-open-external", CommandResult.Completed(invocation, 4, 9, true, new OpenExternalEffect("https://example.test/auth", Command("resume")))), + ("result-denied", CommandResult.Denied(invocation, 5, "not yours", "go")), + ("result-denied-no-node", CommandResult.Denied(invocation, 6, "not allowed", null)), + ("result-failed", CommandResult.Failed(invocation, 7, "broke")), + }; + + public static AssetCatalog FullAssetCatalog() => new( + "provider.a", + "icons", + 2, + new List + { + new("gear", "image/png", "assets/gear.png", 32, 32), + new("bolt", "image/svg+xml", "assets/bolt.svg", 0, 0), + }); + + public static SurfaceDocument SurfaceOf(string provider, string label) => + new(provider, label, 1, "", Boxed("r"), new List()); + + public static CompositionDocument CompositionOf(string publisher, string name, long version) => + new(publisher, name, version, new List(), Boxed("r")); + + public static AssetCatalog CatalogOf(string provider, string id) => new(provider, id, 1, new List()); +} diff --git a/tests/GameCult.Thing.Contract.Tests/DeepTreeTests.cs b/tests/GameCult.Thing.Contract.Tests/DeepTreeTests.cs new file mode 100644 index 0000000..5914a7d --- /dev/null +++ b/tests/GameCult.Thing.Contract.Tests/DeepTreeTests.cs @@ -0,0 +1,61 @@ +using MessagePack; +using static GameCult.Thing.Contract.Tests.Builders; + +namespace GameCult.Thing.Contract.Tests; + +/// +/// CultLib bounds payload nesting at 256 MessagePack containers (the document array is 1). A Box chain costs 3 containers per +/// level (union array, slots array, children list), and the deepest leaf's style adds 4 beyond its slots, so a chain of +/// 84 boxes nests 3 * 84 + 4 = 256 deep and 85 nests 259. Decoding recurses per level, so the default test thread carries +/// the deepest chain the bound admits: this package sets no thread size. +/// +public sealed class DeepTreeTests +{ + private const int DeepestAdmitted = 84; + + private static SurfaceDocument Chain(int levels) + { + var node = Boxed("leaf"); + for (var level = levels - 1; level >= 1; level--) node = Boxed($"deep-{level}", node); + return new SurfaceDocument("provider.a", "deep", 1, "deep", node, new List()); + } + + [Test] + public void TheDeepestChainTheBoundAdmitsRoundTripsOnTheTestThread() + { + var chain = Chain(DeepestAdmitted); + var bytes = Wire.Write(chain); + var decoded = (SurfaceDocument)Wire.Read(typeof(SurfaceDocument), bytes); + Assert.That(Convert.ToHexString(Wire.Write(decoded)), Is.EqualTo(Convert.ToHexString(bytes))); + + var depth = 0; + for (var node = decoded.Root; node is BoxNode box; node = box.Children.SingleOrDefault()!) depth++; + Assert.That(depth, Is.EqualTo(DeepestAdmitted)); + } + + [Test] + public void OneLevelDeeperIsRefusedByTheBoundOnReadAndOnWrite() + { + var chain = Chain(DeepestAdmitted + 1); + + var write = Assert.Throws(() => Wire.Write(chain))!; + Assert.That(write.Message, Does.Contain("payload nests 257").And.Contain("the limit is 256")); + + // The bytes a hostile sender could produce, past the writer's own check. + var bytes = MessagePackSerializer.Serialize(typeof(SurfaceDocument), chain, Wire.Options); + var read = Assert.Throws(() => Wire.Read(typeof(SurfaceDocument), bytes))!; + Assert.That(read.Message, Does.Contain("payload nests 257").And.Contain("the limit is 256")); + } + + [Test] + public void AThousandNodesWideRoundTrips() + { + var children = Enumerable.Range(0, 998).Select(index => Text($"t{index}", "x")).ToList(); + children.Add(Boxed("tail")); + var wide = new SurfaceDocument("provider.a", "wide", 1, "wide", Boxed("root", children.ToArray()), new List()); + var bytes = Wire.Write(wide); + var decoded = (SurfaceDocument)Wire.Read(typeof(SurfaceDocument), bytes); + Assert.That(((BoxNode)decoded.Root).Children, Has.Count.EqualTo(999)); + Assert.That(Convert.ToHexString(Wire.Write(decoded)), Is.EqualTo(Convert.ToHexString(bytes))); + } +} diff --git a/tests/GameCult.Thing.Contract.Tests/DomainTests.cs b/tests/GameCult.Thing.Contract.Tests/DomainTests.cs new file mode 100644 index 0000000..85e94af --- /dev/null +++ b/tests/GameCult.Thing.Contract.Tests/DomainTests.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using MessagePack; +using static GameCult.Thing.Contract.Tests.Builders; + +namespace GameCult.Thing.Contract.Tests; + +/// The four domains, at every constructor that carries one and at every door that reaches a constructor. +public sealed class DomainTests +{ + private static CommandInvocation Inv() => Invocation(); + + // Each domain-carrying field of contract-rs r2, with one out-of-domain value. The expected text is Rust's DomainError Display. + private static IEnumerable OutOfDomain() + { + TestCaseData Case(string field, string message, Action build) => new TestCaseData(message, build).SetName($"OutOfDomain({field})"); + + yield return Case("CommandResult.Seq", "Seq must be at least 1", () => CommandResult.Pending(Inv(), 0, "m")); + yield return Case("CommandResult.Seq min", "Seq must be at least 1", () => CommandResult.Failed(Inv(), int.MinValue, "r")); + yield return Case("CompletedOutcome.SourceVersion", "Version must be at least 0", () => new CompletedOutcome(-1, false, null)); + yield return Case("CommandResult.Completed source version", "Version must be at least 0", () => CommandResult.Completed(Inv(), 1, -1, false, null)); + yield return Case("CommandInvocation.SurfaceVersion", "Version must be at least 0", + () => new CommandInvocation("i", "k", -1, "go", new RecordPayload("s", Array.Empty()), new List(), "c", "t", null)); + yield return Case("SurfaceDocument.Label empty", "Segment must be non-empty", () => SurfaceOf("p", "")); + yield return Case("SurfaceDocument.Label dotted", "Segment must be free of '.'", () => SurfaceOf("p", "a.b")); + yield return Case("SurfaceDocument.Version", "Version must be at least 0", () => new SurfaceDocument("p", "l", -1, "t", Boxed("r"), new List())); + yield return Case("CompositionDocument.Name empty", "Segment must be non-empty", () => CompositionOf("p", "", 1)); + yield return Case("CompositionDocument.Name dotted", "Segment must be free of '.'", () => CompositionOf("p", "a.b", 1)); + yield return Case("CompositionDocument.Version", "Version must be at least 0", () => CompositionOf("p", "n", -1)); + yield return Case("CompositionRef.Name empty", "Segment must be non-empty", () => new CompositionRef("p", "", 1)); + yield return Case("CompositionRef.Name dotted", "Segment must be free of '.'", () => new CompositionRef("p", "a.b", 1)); + yield return Case("CompositionRef.Version", "Version must be at least 0", () => new CompositionRef("p", "n", -1)); + yield return Case("AssetCatalog.CatalogId empty", "Segment must be non-empty", () => CatalogOf("p", "")); + yield return Case("AssetCatalog.CatalogId dotted", "Segment must be free of '.'", () => CatalogOf("p", "a.b")); + yield return Case("AssetCatalog.Version", "Version must be at least 0", () => new AssetCatalog("p", "c", -1, new List())); + yield return Case("AssetEntry.Width", "Count must be at least 0", () => new AssetEntry("i", "m", "u", -1, 0)); + yield return Case("AssetEntry.Height", "Count must be at least 0", () => new AssetEntry("i", "m", "u", 0, int.MinValue)); + yield return Case("TextNode.MaxLines", "Count must be at least 0", + () => new TextNode(Common("t"), LitText("x"), "body", true, -1, false)); + yield return Case("MemberIndex.Index", "Count must be at least 0", () => new MemberIndex(-1)); + yield return Case("NumberFormat.Precision", "Count must be at least 0", () => new NumberFormat(-1, false, null)); + } + + [TestCaseSource(nameof(OutOfDomain))] + public void OutOfDomainConstructionIsRefusedNamingTheDomainAndBound(string message, Action build) + { + var error = Assert.Throws(() => build())!; + Assert.That(error.Message, Is.EqualTo(message)); + } + + [Test] + public void BoundaryValuesAreInDomain() + { + Assert.DoesNotThrow(() => CommandResult.Pending(Inv(), 1, "m")); + Assert.DoesNotThrow(() => CommandResult.Pending(Inv(), int.MaxValue, "m")); + Assert.DoesNotThrow(() => new CompletedOutcome(0, false, null)); + Assert.DoesNotThrow(() => new CompletedOutcome(null, false, null)); + Assert.DoesNotThrow(() => new AssetEntry("i", "m", "u", 0, 0)); + Assert.DoesNotThrow(() => new TextNode(Common("t"), LitText("x"), "body", true, 0, false)); + Assert.DoesNotThrow(() => new TextNode(Common("t"), LitText("x"), "body", true, null, false)); + Assert.DoesNotThrow(() => new MemberIndex(0)); + Assert.DoesNotThrow(() => new CompositionRef("p", "n", 0)); + Assert.DoesNotThrow(() => SurfaceOf("p", "a-b_c")); + } + + [Test] + public void TheMessageNeverEchoesTheValue() + { + var dotted = Assert.Throws(() => SurfaceOf("p", $"{Wire.Canary}.x"))!; + Assert.That(dotted.Message, Is.EqualTo("Segment must be free of '.'")); + Assert.That(dotted.ToString(), Does.Not.Contain(Wire.Canary)); + Assert.That((dotted.Domain, dotted.Bound), Is.EqualTo(("Segment", "free of '.'"))); + + var seq = Assert.Throws(() => CommandResult.Failed(Inv(), -77, "r"))!; + Assert.That(seq.Message, Does.Not.Contain("77")); + var version = Assert.Throws(() => CompositionOf("p", "n", -77))!; + Assert.That(version.Message, Does.Not.Contain("77")); + var count = Assert.Throws(() => new MemberIndex(-77))!; + Assert.That(count.Message, Does.Not.Contain("77")); + } + + // ---- the doors: every deserializer reaches the one constructor ---- + + private static byte[] AssetEntryBytes(int width) + { + var buffer = new System.Buffers.ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(5); + writer.Write("gear"); + writer.Write("image/png"); + writer.Write(Wire.Canary); + writer.Write(width); + writer.Write(32); + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + [Test] + public void CultLibMessagePackRefusesThroughTheConstructor() + { + var error = Assert.Throws( + () => MessagePackSerializer.Deserialize(AssetEntryBytes(-5), Wire.Options))!; + Assert.That(Wire.Flatten(error), Does.Contain("Count must be at least 0")); + Assert.That(Wire.Flatten(error), Does.Not.Contain(Wire.Canary)); + Assert.That(MessagePackSerializer.Deserialize(AssetEntryBytes(5), Wire.Options).Width, Is.EqualTo(5)); + } + + [Test] + public void MessagePackStandardRefusesThroughTheConstructor() + { + var error = Assert.Throws( + () => MessagePackSerializer.Deserialize(AssetEntryBytes(-5), MessagePackSerializerOptions.Standard))!; + Assert.That(Wire.Flatten(error), Does.Contain("Count must be at least 0")); + Assert.That(Wire.Flatten(error), Does.Not.Contain(Wire.Canary)); + Assert.That(MessagePackSerializer.Deserialize(AssetEntryBytes(5), MessagePackSerializerOptions.Standard).Width, Is.EqualTo(5)); + } + + [Test] + public void SystemTextJsonRefusesThroughTheConstructor() + { + string Json(int width) => $"{{\"Id\":\"gear\",\"MediaType\":\"image/png\",\"Uri\":\"{Wire.Canary}\",\"Width\":{width},\"Height\":32}}"; + var error = Assert.Catch(() => JsonSerializer.Deserialize(Json(-5)))!; + Assert.That(Wire.Flatten(error), Does.Contain("Count must be at least 0")); + Assert.That(Wire.Flatten(error), Does.Not.Contain(Wire.Canary)); + Assert.That(JsonSerializer.Deserialize(Json(5))!.Width, Is.EqualTo(5)); + } + + [Test] + public void ResultSeqZeroIsRefusedByEveryDoor() + { + var bytes = Wire.Golden("result-seq-zero.bin"); + + var cult = Assert.Throws(() => Wire.Read(typeof(CommandResult), bytes))!; + Assert.That(Wire.Flatten(cult), Does.Contain("Seq must be at least 1")); + + var standard = Assert.Throws( + () => MessagePackSerializer.Deserialize(bytes, MessagePackSerializerOptions.Standard))!; + Assert.That(Wire.Flatten(standard), Does.Contain("Seq must be at least 1")); + Assert.That(Wire.Flatten(standard), Does.Not.Contain(Wire.Canary)); + + // System.Text.Json has no constructor it may call on a result: the type is refused outright. + Assert.Throws( + () => JsonSerializer.Deserialize("{\"InvocationId\":\"inv-1\",\"Seq\":1,\"Outcome\":null}")); + } + + [Test] + public void ResultsAtSeqOneDecodeThroughTheStandardDoorToo() + { + var bytes = Wire.Golden("result-pending.bin"); + var standard = MessagePackSerializer.Deserialize(bytes, MessagePackSerializerOptions.Standard); + Assert.That(standard.Seq, Is.EqualTo(1)); + Assert.That(standard.Outcome, Is.TypeOf()); + } +} diff --git a/tests/GameCult.Thing.Contract.Tests/FactoriesOnlyTests.cs b/tests/GameCult.Thing.Contract.Tests/FactoriesOnlyTests.cs new file mode 100644 index 0000000..73ba4c9 --- /dev/null +++ b/tests/GameCult.Thing.Contract.Tests/FactoriesOnlyTests.cs @@ -0,0 +1,154 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using MessagePack; +using static GameCult.Thing.Contract.Tests.Builders; + +namespace GameCult.Thing.Contract.Tests; + +/// +/// The package is attacked as an outside consumer would: through its public surface only. A public or init setter would +/// admit an object initializer or a with-expression; a record would admit both; a public union constructor would admit a +/// subtype nobody declared; a public CommandResult constructor would admit a result no factory built. +/// +public sealed class FactoriesOnlyTests +{ + private static readonly Assembly Package = typeof(CommandResult).Assembly; + + private static IEnumerable PublicTypes() => Package.GetExportedTypes(); + + [Test] + public void TheCommandResultHasNoPublicConstructor() + { + Assert.That(typeof(CommandResult).GetConstructors(BindingFlags.Public | BindingFlags.Instance), Is.Empty); + Assert.That(typeof(CommandResult).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance), Has.Length.EqualTo(1)); + } + + [Test] + public void NoContractTypeHasAPublicOrInitSetterAField_OrIsARecord() + { + var offenders = new List(); + foreach (var type in PublicTypes()) + { + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + var setter = property.SetMethod; + if (setter == null) continue; + var init = setter.ReturnParameter.GetRequiredCustomModifiers().Contains(typeof(IsExternalInit)); + if (setter.IsPublic || init || setter.IsAssembly || setter.IsFamily) offenders.Add($"{type.Name}.{property.Name} setter{(init ? " (init)" : "")}"); + } + + foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + offenders.Add($"{type.Name}.{field.Name} public field"); + + if (type.GetMethod("$", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) != null) + offenders.Add($"{type.Name} is a record ($)"); + } + + Assert.That(offenders, Is.Empty); + Assert.That(PublicTypes().Count(), Is.GreaterThan(100), "the scan reached the package's types"); + } + + [Test] + public void EveryUnionBaseConstructorIsNonPublic() + { + var unions = PublicTypes().Where(t => t.IsAbstract && t.GetCustomAttributes(false).Any()).ToList(); + Assert.That(unions, Has.Count.GreaterThan(25)); + foreach (var union in unions) + { + Assert.That(union.GetConstructors(BindingFlags.Public | BindingFlags.Instance), Is.Empty, union.Name); + Assert.That(union.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance), Is.Not.Empty, union.Name); + } + } + + [Test] + public void EveryUnionArmIsSealedAndDeclared() + { + // The arms are the union attributes' types, each sealed and deriving the union directly: no outside subtype exists. + var unions = PublicTypes().Where(t => t.IsAbstract && t.GetCustomAttributes(false).Any()); + foreach (var union in unions) + { + var arms = union.GetCustomAttributes(false).ToList(); + Assert.That(arms.Select(a => a.Key), Is.Unique, union.Name); + foreach (var arm in arms) + { + Assert.That(arm.SubType.IsSealed, Is.True, $"{union.Name}.{arm.SubType.Name}"); + Assert.That(arm.SubType.BaseType, Is.EqualTo(union), $"{union.Name}.{arm.SubType.Name}"); + } + + var derived = Package.GetTypes().Where(t => t.BaseType == union).ToList(); + Assert.That(derived, Is.EquivalentTo(arms.Select(a => a.SubType)), $"{union.Name}: every subtype is a declared arm"); + } + } + + [Test] + public void EveryDocumentCarriesTheCratesSchemaNameAndVersion() + { + var expected = new Dictionary + { + [typeof(SurfaceDocument)] = ("gamecult.thing.surface", "gamecult.thing.surface.v1"), + [typeof(CompositionDocument)] = ("gamecult.thing.composition", "gamecult.thing.composition.v1"), + [typeof(CommandInvocation)] = ("gamecult.thing.command_invocation", "gamecult.thing.command_invocation.v1"), + [typeof(CommandResult)] = ("gamecult.thing.command_result", "gamecult.thing.command_result.v1"), + [typeof(AssetCatalog)] = ("gamecult.thing.asset_catalog", "gamecult.thing.asset_catalog.v1"), + }; + foreach (var (type, (name, version)) in expected) + { + var attribute = type.GetCustomAttribute()!; + Assert.That((attribute.SchemaName, attribute.SchemaVersion), Is.EqualTo((name, version)), type.Name); + } + + var documents = PublicTypes().Where(t => t.GetCustomAttribute() != null).ToList(); + Assert.That(documents, Is.EquivalentTo(expected.Keys)); + } + + [Test] + public void TheFactoriesCopyTheInvocationIdAndHoldSeq() + { + var invocation = InvocationNamed("an-invocation-no-constructor-can-guess"); + var built = new[] + { + CommandResult.Pending(invocation, 1, "m"), + CommandResult.Completed(invocation, 2, null, false, null), + CommandResult.Denied(invocation, 3, "r", null), + CommandResult.Failed(invocation, 4, "r"), + }; + for (var index = 0; index < built.Length; index++) + { + Assert.That(built[index].InvocationId, Is.EqualTo("an-invocation-no-constructor-can-guess")); + Assert.That(built[index].Seq, Is.EqualTo(index + 1)); + } + + Assert.That(built[0].Outcome, Is.TypeOf()); + Assert.That(((PendingOutcome)built[0].Outcome).Message, Is.EqualTo("m")); + Assert.That(built[1].Outcome, Is.TypeOf()); + Assert.That(built[2].Outcome, Is.TypeOf()); + Assert.That(built[3].Outcome, Is.TypeOf()); + } + + [Test] + public void TheFactoriesFillEveryOutcomeSlot() + { + var invocation = InvocationNamed("i"); + var effect = new OpenSurfaceEffect("k"); + var completed = (CompletedOutcome)CommandResult.Completed(invocation, 1, 5, true, effect).Outcome; + Assert.That((completed.SourceVersion, completed.ClearDrafts, completed.Effect), Is.EqualTo(((long?)5, true, (Effect?)effect))); + var bare = (CompletedOutcome)CommandResult.Completed(invocation, 1, null, false, null).Outcome; + Assert.That((bare.SourceVersion, bare.ClearDrafts, bare.Effect), Is.EqualTo(((long?)null, false, (Effect?)null))); + var denied = (DeniedOutcome)CommandResult.Denied(invocation, 1, "no", "go").Outcome; + Assert.That((denied.Reason, denied.Node), Is.EqualTo(("no", (string?)"go"))); + var failed = (FailedOutcome)CommandResult.Failed(invocation, 1, "broke").Outcome; + Assert.That(failed.Reason, Is.EqualTo("broke")); + } + + [Test] + public void UnitArmsAreSharedInstancesAndWriteAnEmptySlotArray() + { + Assert.That(ImageFit.Contain, Is.SameAs(ImageFit.Contain)); + Assert.That(ImageFit.Contain, Is.Not.SameAs(ImageFit.Cover)); + // [key, []]: array(2), the key, array(0). + Assert.That(MessagePackSerializer.Serialize(ImageFit.Fill, Wire.Options), Is.EqualTo(new byte[] { 0x92, 0x02, 0x90 })); + Assert.That(MessagePackSerializer.Serialize(InputKind.Number, Wire.Options), Is.EqualTo(new byte[] { 0x92, 0x01, 0x90 })); + var read = MessagePackSerializer.Deserialize(new byte[] { 0x92, 0x03, 0x90 }, Wire.Options); + Assert.That(read, Is.TypeOf(ImageFit.Unscaled.GetType())); + } +} diff --git a/tests/GameCult.Thing.Contract.Tests/GameCult.Thing.Contract.Tests.csproj b/tests/GameCult.Thing.Contract.Tests/GameCult.Thing.Contract.Tests.csproj new file mode 100644 index 0000000..6d7164b --- /dev/null +++ b/tests/GameCult.Thing.Contract.Tests/GameCult.Thing.Contract.Tests.csproj @@ -0,0 +1,32 @@ + + + net10.0 + enable + enable + false + true + obj\bin\ + $(MSBuildThisFileDirectory)..\..\..\CultLib + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/GameCult.Thing.Contract.Tests/GoldenTests.cs b/tests/GameCult.Thing.Contract.Tests/GoldenTests.cs new file mode 100644 index 0000000..258e54a --- /dev/null +++ b/tests/GameCult.Thing.Contract.Tests/GoldenTests.cs @@ -0,0 +1,138 @@ +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using static GameCult.Thing.Contract.Tests.Builders; + +namespace GameCult.Thing.Contract.Tests; + +public sealed class GoldenTests +{ + private static readonly string[] RefusalGoldens = + { + "result-thing1-flat", "node-unknown-arm", "outcome-unknown-arm", "result-extra-slot", "result-seq-zero", + "surface-version-negative", "surface-label-dotted", "asset-extent-negative", "rgba-channel-out-of-range", + }; + + private static IEnumerable ValueGoldens() + { + yield return new TestCaseData("surface-all-arms", (Func)FullSurface); + yield return new TestCaseData("composition-all-params", (Func)FullComposition); + yield return new TestCaseData("invocation-all-inputs", (Func)Invocation); + foreach (var (name, _) in Results(Invocation())) + { + var captured = name; + yield return new TestCaseData(captured, (Func)(() => Results(Invocation()).Single(r => r.Name == captured).Result)); + } + yield return new TestCaseData("asset-catalog", (Func)FullAssetCatalog); + } + + [TestCaseSource(nameof(ValueGoldens))] + public void Goldens(string name, Func build) + { + var value = build(); + var golden = Wire.Golden($"{name}.bin"); + + Assert.That(Convert.ToHexString(Wire.Write(value)), Is.EqualTo(Convert.ToHexString(golden)), $"{name}: the package writes the committed bytes"); + + var decoded = Wire.Read(value.GetType(), golden); + Assert.That(Convert.ToHexString(Wire.Write(decoded)), Is.EqualTo(Convert.ToHexString(golden)), $"{name}: decoding and re-encoding keeps the bytes"); + } + + [Test] + public void EveryBinGoldenIsNamedByACase() + { + var named = ValueGoldens().Select(c => (string)c.Arguments[0]!).Concat(RefusalGoldens) + .OrderBy(n => n, StringComparer.Ordinal).ToArray(); + var onDisk = Directory.GetFiles(Path.GetDirectoryName(Wire.GoldenPath("x"))!, "*.bin") + .Select(f => Path.GetFileNameWithoutExtension(f)).OrderBy(n => n, StringComparer.Ordinal).ToArray(); + Assert.That(onDisk, Is.EqualTo(named)); + } + + // ---- refusals ---- + + private static string Refused(Type type, string golden) + { + var bytes = Wire.Golden($"{golden}.bin"); + var error = Assert.Throws(() => Wire.Read(type, bytes))!; + var text = Wire.Flatten(error); + Assert.That(text, Does.Not.Contain(Wire.Canary), $"{golden}: the refusal echoes no value"); + return text; + } + + [Test] + public void Thing1FlatMapIsRefused() => Refused(typeof(CommandResult), "result-thing1-flat"); + + [TestCase("node-unknown-arm", typeof(SurfaceDocument), "Node", "13")] + [TestCase("outcome-unknown-arm", typeof(CommandResult), "Outcome", "4")] + public void UnknownArmsAreRefusedNamingUnionAndKey(string golden, Type type, string union, string key) + { + var text = Refused(type, golden); + Assert.That(text, Does.Contain($"{typeof(CommandResult).Namespace}.{union} has no arm for key {key}")); + } + + [TestCase("result-seq-zero", typeof(CommandResult), "Seq")] + [TestCase("surface-version-negative", typeof(SurfaceDocument), "Version")] + [TestCase("surface-label-dotted", typeof(SurfaceDocument), "Segment")] + [TestCase("asset-extent-negative", typeof(AssetCatalog), "Count")] + public void DomainGoldensAreRefusedNamingTheDomain(string golden, Type type, string domain) + { + Assert.That(Refused(type, golden), Does.Contain($"{domain} must be")); + } + + [Test] + public void RgbaChannelAbove255IsRefused() + { + var bytes = Wire.Golden("rgba-channel-out-of-range.bin"); + var error = Assert.Throws( + () => MessagePackSerializer.Deserialize(bytes, Wire.Options))!; + var text = Wire.Flatten(error); + Assert.That(text, Does.Not.Contain(Wire.Canary)); + Assert.That(text, Does.Contain("Rgba")); + } + + [Test] + public void ExtraTrailingSlotIsReadAsPendingSeqOne() + { + var golden = Wire.Golden("result-extra-slot.bin"); + var result = (CommandResult)Wire.Read(typeof(CommandResult), golden); + Assert.That(result.InvocationId, Is.EqualTo("inv-1")); + Assert.That(result.Seq, Is.EqualTo(1)); + Assert.That(result.Outcome, Is.TypeOf()); + Assert.That(((PendingOutcome)result.Outcome).Message, Is.EqualTo("working")); + // Read, never written back: re-encoding drops the extra slot. + Assert.That(Wire.Write(result).Length, Is.LessThan(golden.Length)); + } + + // ---- the store ---- + + [Test] + public void StoreRead() + { + var scratch = Path.Combine(Path.GetTempPath(), $"thing-contract-store-{Guid.NewGuid():N}.cc"); + File.Copy(Wire.GoldenPath("surface.cc"), scratch); + try + { + using var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Wire.Registry, ReadOnly = true }); + + var cases = new (object Value, string Golden, string Key)[] + { + (cache.GetAll().Single(), "surface-all-arms", "thing:surface:provider.a.main"), + (cache.GetAll().Single(), "composition-all-params", "thing:composition:provider.a.panel.v3"), + (cache.GetAll().Single(), "invocation-all-inputs", "thing:invocation:3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b"), + (cache.GetAll().Single(), "result-pending", "thing:result:3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b.1"), + (cache.GetAll().Single(), "asset-catalog", "thing:asset_catalog:provider.a.icons"), + }; + foreach (var (value, golden, key) in cases) + { + Assert.That(Convert.ToHexString(Wire.Write(value)), Is.EqualTo(Convert.ToHexString(Wire.Golden($"{golden}.bin"))), golden); + var recordKey = (string)value.GetType().GetProperty("RecordKey")!.GetValue(value)!; + Assert.That(recordKey, Is.EqualTo(key), golden); + Assert.That(cache.Get(new CultRecordKey(recordKey)), Is.SameAs(value), $"{golden}: stored under the package's own key"); + } + } + finally + { + File.Delete(scratch); + } + } +} diff --git a/tests/GameCult.Thing.Contract.Tests/KeyTests.cs b/tests/GameCult.Thing.Contract.Tests/KeyTests.cs new file mode 100644 index 0000000..87c0918 --- /dev/null +++ b/tests/GameCult.Thing.Contract.Tests/KeyTests.cs @@ -0,0 +1,66 @@ +using static GameCult.Thing.Contract.Tests.Builders; + +namespace GameCult.Thing.Contract.Tests; + +public sealed class KeyTests +{ + [Test] + public void KeysAreBuiltFromTheFields() + { + var invocation = InvocationNamed("an-invocation-no-constructor-can-guess"); + Assert.That(invocation.RecordKey, Is.EqualTo("thing:invocation:an-invocation-no-constructor-can-guess")); + Assert.That(CommandResult.Failed(invocation, 3, "r").RecordKey, Is.EqualTo("thing:result:an-invocation-no-constructor-can-guess.3")); + Assert.That(SurfaceOf("p", "main").RecordKey, Is.EqualTo("thing:surface:p.main")); + Assert.That(CompositionOf("pub", "card", 2).RecordKey, Is.EqualTo("thing:composition:pub.card.v2")); + Assert.That(CatalogOf("p", "icons").RecordKey, Is.EqualTo("thing:asset_catalog:p.icons")); + } + + [Test] + public void KeysAreInjective() + { + Assert.That(SurfaceOf("a.b", "c").RecordKey, Is.EqualTo("thing:surface:a.b.c")); + Assert.Throws(() => SurfaceOf("a", "b.c")); + Assert.That(SurfaceOf("a", "c").RecordKey, Is.Not.EqualTo(SurfaceOf("a.b", "c").RecordKey)); + Assert.That(SurfaceOf("a.b", "c").RecordKey, Is.Not.EqualTo(SurfaceOf("a", "bc").RecordKey)); + + Assert.That(CompositionOf("a.b", "c", 1).RecordKey, Is.EqualTo("thing:composition:a.b.c.v1")); + Assert.That(CompositionOf("a.b", "c", 1).RecordKey, Is.Not.EqualTo(CompositionOf("a", "bc", 1).RecordKey)); + Assert.That(CompositionOf("a", "c", 1).RecordKey, Is.Not.EqualTo(CompositionOf("a", "c", 11).RecordKey)); + Assert.That(CompositionOf("a", "c", 1).RecordKey, Is.Not.EqualTo(CompositionOf("a", "c", 0).RecordKey)); + + Assert.That(CatalogOf("a.b", "c").RecordKey, Is.EqualTo("thing:asset_catalog:a.b.c")); + Assert.That(CatalogOf("a.b", "c").RecordKey, Is.Not.EqualTo(CatalogOf("a", "bc").RecordKey)); + + var first = CommandResult.Failed(InvocationNamed("x.1"), 2, "r").RecordKey; + var second = CommandResult.Failed(InvocationNamed("x"), 12, "r").RecordKey; + Assert.That(first, Is.EqualTo("thing:result:x.1.2")); + Assert.That(second, Is.EqualTo("thing:result:x.12")); + Assert.That(first, Is.Not.EqualTo(second)); + } + + [TestCase("")] + [TestCase(".")] + [TestCase("a.b")] + public void EveryKeyBuilderRefusesABadSegment(string segment) + { + void Names(TestDelegate act) + { + var error = Assert.Throws(act)!; + Assert.That(error.Message, Does.StartWith("Segment must be")); + } + + Names(() => ThingKeys.SurfaceKey("p", segment)); + Names(() => ThingKeys.CompositionKey("p", segment, 1)); + Names(() => ThingKeys.AssetCatalogKey("p", segment)); + Names(() => SurfaceOf("p", segment)); + Names(() => CompositionOf("p", segment, 1)); + Names(() => CatalogOf("p", segment)); + } + + [Test] + public void KeyBuildersRefuseOutOfDomainNumbers() + { + Assert.Throws(() => ThingKeys.CompositionKey("p", "n", -1)); + Assert.Throws(() => ThingKeys.ResultKey("i", 0)); + } +} diff --git a/tests/GameCult.Thing.Contract.Tests/Wire.cs b/tests/GameCult.Thing.Contract.Tests/Wire.cs new file mode 100644 index 0000000..ab40c4b --- /dev/null +++ b/tests/GameCult.Thing.Contract.Tests/Wire.cs @@ -0,0 +1,40 @@ +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; + +namespace GameCult.Thing.Contract.Tests; + +/// The one way the tests reach the wire: CultLib's document serialization and the committed Rust goldens. +internal static class Wire +{ + public const string Canary = "CANARY-7f3a91c2"; + + public static readonly Type[] DocumentTypes = + { + typeof(SurfaceDocument), typeof(CompositionDocument), typeof(CommandInvocation), typeof(CommandResult), typeof(AssetCatalog), + }; + + public static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(DocumentTypes); + + public static MessagePackSerializerOptions Options => CultDocumentMessagePackSerialization.OptionsFor(typeof(CommandResult).Assembly); + + public static byte[] Write(object value) => CultDocumentMessagePackSerialization.SerializeUntyped(value, value.GetType(), Registry); + + public static object Read(Type type, byte[] bytes) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, bytes, Registry); + + public static string GoldenPath(string file) => Path.Combine(AppContext.BaseDirectory, "goldens", file); + + public static byte[] Golden(string file) + { + var bytes = File.ReadAllBytes(GoldenPath(file)); + Assert.That(bytes, Is.Not.Empty, file); + return bytes; + } + + public static string Flatten(Exception error) + { + var parts = new List(); + for (Exception? e = error; e != null; e = e.InnerException) parts.Add($"{e.GetType().Name}: {e.Message}"); + return string.Join(" | ", parts); + } +}