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