GameCult.Thing.Contract.Tests: the package held to the Rust goldens byte for byte, and attacked as an outside consumer
Goldens, refusals, store read, keys, domains at every constructor and every door (CultLib MessagePack, MessagePack Standard, System.Text.Json), factories-only reflection, deepest chain the 256 bound admits. Goldens are linked from crates/thing-contract/tests/goldens, read only. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
33eed442c7
commit
280979c86d
8 changed files with 941 additions and 0 deletions
297
tests/GameCult.Thing.Contract.Tests/Builders.cs
Normal file
297
tests/GameCult.Thing.Contract.Tests/Builders.cs
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
using GameCult.Thing.Contract;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Dimension>? gridColumns = null,
|
||||
List<Dimension>? 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<Dimension>(),
|
||||
gridRows ?? new List<Dimension>());
|
||||
|
||||
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<CapturedInput>
|
||||
{
|
||||
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<CapturedInput>(), "renderer.web", "2026-01-01T00:00:00Z", null);
|
||||
|
||||
public static List<BindParts> BindAllFormats()
|
||||
{
|
||||
var number = new NumberFormat(2, true, "ms");
|
||||
var affix = new AffixFormat("$", "/s");
|
||||
var indexed = new MemberPath(new List<MemberStep> { new MemberName("items"), new MemberIndex(3) });
|
||||
return new List<BindParts>
|
||||
{
|
||||
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<Dimension> { new DimensionFr(1), Px(120) },
|
||||
gridRows: new List<Dimension> { Dimension.Content }),
|
||||
new Paint(new ThemeToken("surface.raised"), new Border(new Rgba(1, 2, 3, 255), 1.5), 4.0),
|
||||
Role.Region,
|
||||
new List<Node> { 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<Node>
|
||||
{
|
||||
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<Node> { 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<SelectOption> { 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<Node> { Text("menu-item", "one") }),
|
||||
new OverlayNode(Common("dialog"), Anchor.Viewport, true, Command("close"), new List<Node>()),
|
||||
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<Arg> { 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<Node> { 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<Node>()));
|
||||
}
|
||||
|
||||
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<Node>()));
|
||||
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<CommandDecl>
|
||||
{
|
||||
new("go", "gamecult.thing.test.go.v1", new List<string> { "name-input", "level" }, "Go now?"),
|
||||
new("close", "gamecult.thing.test.close.v1", new List<string>(), null),
|
||||
});
|
||||
}
|
||||
|
||||
public static CompositionDocument FullComposition()
|
||||
{
|
||||
var args = new List<Arg>
|
||||
{
|
||||
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<Node> { 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<ParamDecl>
|
||||
{
|
||||
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<AssetEntry>
|
||||
{
|
||||
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<CommandDecl>());
|
||||
|
||||
public static CompositionDocument CompositionOf(string publisher, string name, long version) =>
|
||||
new(publisher, name, version, new List<ParamDecl>(), Boxed("r"));
|
||||
|
||||
public static AssetCatalog CatalogOf(string provider, string id) => new(provider, id, 1, new List<AssetEntry>());
|
||||
}
|
||||
61
tests/GameCult.Thing.Contract.Tests/DeepTreeTests.cs
Normal file
61
tests/GameCult.Thing.Contract.Tests/DeepTreeTests.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using MessagePack;
|
||||
using static GameCult.Thing.Contract.Tests.Builders;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<CommandDecl>());
|
||||
}
|
||||
|
||||
[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<MessagePackSerializationException>(() => 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<MessagePackSerializationException>(() => 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<CommandDecl>());
|
||||
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)));
|
||||
}
|
||||
}
|
||||
153
tests/GameCult.Thing.Contract.Tests/DomainTests.cs
Normal file
153
tests/GameCult.Thing.Contract.Tests/DomainTests.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
using System.Text.Json;
|
||||
using MessagePack;
|
||||
using static GameCult.Thing.Contract.Tests.Builders;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>The four domains, at every constructor that carries one and at every door that reaches a constructor.</summary>
|
||||
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<TestCaseData> 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<byte>()), new List<CapturedInput>(), "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<CommandDecl>()));
|
||||
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<AssetEntry>()));
|
||||
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<ThingDomainException>(() => 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<ThingDomainException>(() => 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<ThingDomainException>(() => CommandResult.Failed(Inv(), -77, "r"))!;
|
||||
Assert.That(seq.Message, Does.Not.Contain("77"));
|
||||
var version = Assert.Throws<ThingDomainException>(() => CompositionOf("p", "n", -77))!;
|
||||
Assert.That(version.Message, Does.Not.Contain("77"));
|
||||
var count = Assert.Throws<ThingDomainException>(() => 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<byte>();
|
||||
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<MessagePackSerializationException>(
|
||||
() => MessagePackSerializer.Deserialize<AssetEntry>(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<AssetEntry>(AssetEntryBytes(5), Wire.Options).Width, Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MessagePackStandardRefusesThroughTheConstructor()
|
||||
{
|
||||
var error = Assert.Throws<MessagePackSerializationException>(
|
||||
() => MessagePackSerializer.Deserialize<AssetEntry>(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<AssetEntry>(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<AssetEntry>(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<AssetEntry>(Json(5))!.Width, Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResultSeqZeroIsRefusedByEveryDoor()
|
||||
{
|
||||
var bytes = Wire.Golden("result-seq-zero.bin");
|
||||
|
||||
var cult = Assert.Throws<MessagePackSerializationException>(() => Wire.Read(typeof(CommandResult), bytes))!;
|
||||
Assert.That(Wire.Flatten(cult), Does.Contain("Seq must be at least 1"));
|
||||
|
||||
var standard = Assert.Throws<MessagePackSerializationException>(
|
||||
() => MessagePackSerializer.Deserialize<CommandResult>(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<NotSupportedException>(
|
||||
() => JsonSerializer.Deserialize<CommandResult>("{\"InvocationId\":\"inv-1\",\"Seq\":1,\"Outcome\":null}"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResultsAtSeqOneDecodeThroughTheStandardDoorToo()
|
||||
{
|
||||
var bytes = Wire.Golden("result-pending.bin");
|
||||
var standard = MessagePackSerializer.Deserialize<CommandResult>(bytes, MessagePackSerializerOptions.Standard);
|
||||
Assert.That(standard.Seq, Is.EqualTo(1));
|
||||
Assert.That(standard.Outcome, Is.TypeOf<PendingOutcome>());
|
||||
}
|
||||
}
|
||||
154
tests/GameCult.Thing.Contract.Tests/FactoriesOnlyTests.cs
Normal file
154
tests/GameCult.Thing.Contract.Tests/FactoriesOnlyTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class FactoriesOnlyTests
|
||||
{
|
||||
private static readonly Assembly Package = typeof(CommandResult).Assembly;
|
||||
|
||||
private static IEnumerable<Type> 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<string>();
|
||||
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("<Clone>$", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) != null)
|
||||
offenders.Add($"{type.Name} is a record (<Clone>$)");
|
||||
}
|
||||
|
||||
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<UnionAttribute>(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<UnionAttribute>(false).Any());
|
||||
foreach (var union in unions)
|
||||
{
|
||||
var arms = union.GetCustomAttributes<UnionAttribute>(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<Type, (string, string)>
|
||||
{
|
||||
[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<GameCult.Caching.CultDocumentAttribute>()!;
|
||||
Assert.That((attribute.SchemaName, attribute.SchemaVersion), Is.EqualTo((name, version)), type.Name);
|
||||
}
|
||||
|
||||
var documents = PublicTypes().Where(t => t.GetCustomAttribute<GameCult.Caching.CultDocumentAttribute>() != 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<PendingOutcome>());
|
||||
Assert.That(((PendingOutcome)built[0].Outcome).Message, Is.EqualTo("m"));
|
||||
Assert.That(built[1].Outcome, Is.TypeOf<CompletedOutcome>());
|
||||
Assert.That(built[2].Outcome, Is.TypeOf<DeniedOutcome>());
|
||||
Assert.That(built[3].Outcome, Is.TypeOf<FailedOutcome>());
|
||||
}
|
||||
|
||||
[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>(ImageFit.Fill, Wire.Options), Is.EqualTo(new byte[] { 0x92, 0x02, 0x90 }));
|
||||
Assert.That(MessagePackSerializer.Serialize<InputKind>(InputKind.Number, Wire.Options), Is.EqualTo(new byte[] { 0x92, 0x01, 0x90 }));
|
||||
var read = MessagePackSerializer.Deserialize<ImageFit>(new byte[] { 0x92, 0x03, 0x90 }, Wire.Options);
|
||||
Assert.That(read, Is.TypeOf(ImageFit.Unscaled.GetType()));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<BaseOutputPath>obj\bin\</BaseOutputPath>
|
||||
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\CultLib</CultLibRoot>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="NUnit.Framework" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="NUnit" Version="4.2.2" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\packages\GameCult.Thing.Contract\GameCult.Thing.Contract.csproj" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\crates\thing-contract\tests\goldens\*"
|
||||
Link="goldens\%(Filename)%(Extension)"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
138
tests/GameCult.Thing.Contract.Tests/GoldenTests.cs
Normal file
138
tests/GameCult.Thing.Contract.Tests/GoldenTests.cs
Normal file
|
|
@ -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<TestCaseData> ValueGoldens()
|
||||
{
|
||||
yield return new TestCaseData("surface-all-arms", (Func<object>)FullSurface);
|
||||
yield return new TestCaseData("composition-all-params", (Func<object>)FullComposition);
|
||||
yield return new TestCaseData("invocation-all-inputs", (Func<object>)Invocation);
|
||||
foreach (var (name, _) in Results(Invocation()))
|
||||
{
|
||||
var captured = name;
|
||||
yield return new TestCaseData(captured, (Func<object>)(() => Results(Invocation()).Single(r => r.Name == captured).Result));
|
||||
}
|
||||
yield return new TestCaseData("asset-catalog", (Func<object>)FullAssetCatalog);
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(ValueGoldens))]
|
||||
public void Goldens(string name, Func<object> 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<MessagePackSerializationException>(() => 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<MessagePackSerializationException>(
|
||||
() => MessagePackSerializer.Deserialize<Rgba>(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<PendingOutcome>());
|
||||
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<SurfaceDocument>().Single(), "surface-all-arms", "thing:surface:provider.a.main"),
|
||||
(cache.GetAll<CompositionDocument>().Single(), "composition-all-params", "thing:composition:provider.a.panel.v3"),
|
||||
(cache.GetAll<CommandInvocation>().Single(), "invocation-all-inputs", "thing:invocation:3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b"),
|
||||
(cache.GetAll<CommandResult>().Single(), "result-pending", "thing:result:3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b.1"),
|
||||
(cache.GetAll<AssetCatalog>().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
66
tests/GameCult.Thing.Contract.Tests/KeyTests.cs
Normal file
66
tests/GameCult.Thing.Contract.Tests/KeyTests.cs
Normal file
|
|
@ -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<ThingDomainException>(() => 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<ThingDomainException>(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<ThingDomainException>(() => ThingKeys.CompositionKey("p", "n", -1));
|
||||
Assert.Throws<ThingDomainException>(() => ThingKeys.ResultKey("i", 0));
|
||||
}
|
||||
}
|
||||
40
tests/GameCult.Thing.Contract.Tests/Wire.cs
Normal file
40
tests/GameCult.Thing.Contract.Tests/Wire.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>The one way the tests reach the wire: CultLib's document serialization and the committed Rust goldens.</summary>
|
||||
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<string>();
|
||||
for (Exception? e = error; e != null; e = e.InnerException) parts.Add($"{e.GetType().Name}: {e.Message}");
|
||||
return string.Join(" | ", parts);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue