GameCult.Thing.Contract: nil refused in every required slot, lists read-only, slot 2 is InputKind
ThingDomain.Present and Items; every constructor, factory and key builder passes each non-nullable reference parameter through Present (refusal names the slot, never a value) and each list through Items (a private read-only copy, nil items refused). Lists are IReadOnlyList on every type. TextInputNode's slot 2 property and parameter are InputKind, matching Rust input_kind. Tests: NilPositionTests reads the Rust vector row by row; PresenceTests and ReadOnlyListTests walk every door by reflection; InputKindNameTests. NameSetTests and the unit-arm rewrite follow. Unit-arm statics are still present in this commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
85baafcb1d
commit
ee80246cd0
15 changed files with 553 additions and 196 deletions
|
|
@ -15,9 +15,9 @@ public sealed class AssetEntry
|
|||
|
||||
public AssetEntry(string id, string mediaType, string uri, int width, int height)
|
||||
{
|
||||
Id = id;
|
||||
MediaType = mediaType;
|
||||
Uri = uri;
|
||||
Id = ThingDomain.Present(id, "Id");
|
||||
MediaType = ThingDomain.Present(mediaType, "MediaType");
|
||||
Uri = ThingDomain.Present(uri, "Uri");
|
||||
Width = ThingDomain.Count(width);
|
||||
Height = ThingDomain.Count(height);
|
||||
}
|
||||
|
|
@ -30,14 +30,14 @@ public sealed class AssetCatalog
|
|||
[Key(0)] public string ProviderId { get; }
|
||||
[Key(1)] public string CatalogId { get; }
|
||||
[Key(2)] public long Version { get; }
|
||||
[Key(3)] public List<AssetEntry> Assets { get; }
|
||||
[Key(3)] public IReadOnlyList<AssetEntry> Assets { get; }
|
||||
|
||||
public AssetCatalog(string providerId, string catalogId, long version, List<AssetEntry> assets)
|
||||
public AssetCatalog(string providerId, string catalogId, long version, IReadOnlyList<AssetEntry> assets)
|
||||
{
|
||||
ProviderId = providerId;
|
||||
ProviderId = ThingDomain.Present(providerId, "ProviderId");
|
||||
CatalogId = ThingDomain.Segment(catalogId);
|
||||
Version = ThingDomain.Version(version);
|
||||
Assets = assets;
|
||||
Assets = ThingDomain.Items(assets, "Assets");
|
||||
}
|
||||
|
||||
[IgnoreMember] public string RecordKey => ThingKeys.AssetCatalogKey(ProviderId, CatalogId);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ public sealed class CommandIdRef : CommandRef
|
|||
|
||||
public CommandIdRef(string id)
|
||||
{
|
||||
Id = id;
|
||||
Id = ThingDomain.Present(id, "Id");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ public sealed class CommandRefParam : CommandRef
|
|||
|
||||
public CommandRefParam(string name)
|
||||
{
|
||||
Name = name;
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ public sealed class TextValue : InputValue
|
|||
|
||||
public TextValue(string value)
|
||||
{
|
||||
Value = value;
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ public sealed class ChoiceValue : InputValue
|
|||
|
||||
public ChoiceValue(string value)
|
||||
{
|
||||
Value = value;
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,14 +91,14 @@ public sealed class CommandDecl
|
|||
{
|
||||
[Key(0)] public string Id { get; }
|
||||
[Key(1)] public string PayloadSchema { get; }
|
||||
[Key(2)] public List<string> Captures { get; }
|
||||
[Key(2)] public IReadOnlyList<string> Captures { get; }
|
||||
[Key(3)] public string? Confirm { get; }
|
||||
|
||||
public CommandDecl(string id, string payloadSchema, List<string> captures, string? confirm)
|
||||
public CommandDecl(string id, string payloadSchema, IReadOnlyList<string> captures, string? confirm)
|
||||
{
|
||||
Id = id;
|
||||
PayloadSchema = payloadSchema;
|
||||
Captures = captures;
|
||||
Id = ThingDomain.Present(id, "Id");
|
||||
PayloadSchema = ThingDomain.Present(payloadSchema, "PayloadSchema");
|
||||
Captures = ThingDomain.Items(captures, "Captures");
|
||||
Confirm = confirm;
|
||||
}
|
||||
}
|
||||
|
|
@ -111,8 +111,8 @@ public sealed class RecordPayload
|
|||
|
||||
public RecordPayload(string schema, byte[] bytes)
|
||||
{
|
||||
Schema = schema;
|
||||
Bytes = bytes;
|
||||
Schema = ThingDomain.Present(schema, "Schema");
|
||||
Bytes = ThingDomain.Present(bytes, "Bytes");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,8 +124,8 @@ public sealed class CapturedInput
|
|||
|
||||
public CapturedInput(string node, InputValue value)
|
||||
{
|
||||
Node = node;
|
||||
Value = value;
|
||||
Node = ThingDomain.Present(node, "Node");
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -138,21 +138,21 @@ public sealed class CommandInvocation
|
|||
[Key(2)] public long SurfaceVersion { get; }
|
||||
[Key(3)] public string Command { get; }
|
||||
[Key(4)] public RecordPayload Payload { get; }
|
||||
[Key(5)] public List<CapturedInput> Captured { get; }
|
||||
[Key(5)] public IReadOnlyList<CapturedInput> Captured { get; }
|
||||
[Key(6)] public string Client { get; }
|
||||
[Key(7)] public string IssuedAt { get; }
|
||||
[Key(8)] public string? Delegation { get; }
|
||||
|
||||
public CommandInvocation(string invocationId, string surfaceKey, long surfaceVersion, string command, RecordPayload payload, List<CapturedInput> captured, string client, string issuedAt, string? delegation)
|
||||
public CommandInvocation(string invocationId, string surfaceKey, long surfaceVersion, string command, RecordPayload payload, IReadOnlyList<CapturedInput> captured, string client, string issuedAt, string? delegation)
|
||||
{
|
||||
InvocationId = invocationId;
|
||||
SurfaceKey = surfaceKey;
|
||||
InvocationId = ThingDomain.Present(invocationId, "InvocationId");
|
||||
SurfaceKey = ThingDomain.Present(surfaceKey, "SurfaceKey");
|
||||
SurfaceVersion = ThingDomain.Version(surfaceVersion);
|
||||
Command = command;
|
||||
Payload = payload;
|
||||
Captured = captured;
|
||||
Client = client;
|
||||
IssuedAt = issuedAt;
|
||||
Command = ThingDomain.Present(command, "Command");
|
||||
Payload = ThingDomain.Present(payload, "Payload");
|
||||
Captured = ThingDomain.Items(captured, "Captured");
|
||||
Client = ThingDomain.Present(client, "Client");
|
||||
IssuedAt = ThingDomain.Present(issuedAt, "IssuedAt");
|
||||
Delegation = delegation;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace GameCult.Thing.Contract;
|
||||
|
||||
|
|
@ -19,7 +20,8 @@ public sealed class ThingDomainException : Exception
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// The value domains of the contract's numbers and local names. Every constructor whose field carries a domain calls the
|
||||
/// The value domains of the contract's numbers, local names and slots. A required slot is never null, and a list is a
|
||||
/// private copy that holds no nil item. Every constructor whose field carries a domain calls the
|
||||
/// matching check, and every deserializer reaches a value through that constructor, so no door skips a check.
|
||||
/// </summary>
|
||||
public static class ThingDomain
|
||||
|
|
@ -42,4 +44,21 @@ public static class ThingDomain
|
|||
string.IsNullOrEmpty(value) ? throw new ThingDomainException("Segment", "non-empty")
|
||||
: value.IndexOf('.') >= 0 ? throw new ThingDomainException("Segment", "free of '.'")
|
||||
: value;
|
||||
|
||||
/// <summary>A required slot: the value, or a refusal naming the slot when it is null.</summary>
|
||||
public static T Present<T>(T? value, string slot) where T : class =>
|
||||
value ?? throw new ThingDomainException(slot, "present");
|
||||
|
||||
/// <summary>
|
||||
/// A required list slot: a read-only copy, so the caller's list cannot change the value afterwards. A null list or
|
||||
/// a null item is refused naming the slot.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<T> Items<T>(IReadOnlyList<T>? items, string slot) where T : class
|
||||
{
|
||||
if (items is null) throw new ThingDomainException(slot, "present");
|
||||
var copy = new T[items.Count];
|
||||
for (var i = 0; i < copy.Length; i++)
|
||||
copy[i] = items[i] ?? throw new ThingDomainException(slot, "free of nil items");
|
||||
return Array.AsReadOnly(copy);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,16 @@ namespace GameCult.Thing.Contract;
|
|||
public static class ThingKeys
|
||||
{
|
||||
public static string SurfaceKey(string providerId, string label) =>
|
||||
$"thing:surface:{providerId}.{ThingDomain.Segment(label)}";
|
||||
$"thing:surface:{ThingDomain.Present(providerId, "ProviderId")}.{ThingDomain.Segment(label)}";
|
||||
|
||||
public static string CompositionKey(string publisher, string name, long version) =>
|
||||
$"thing:composition:{publisher}.{ThingDomain.Segment(name)}.v{ThingDomain.Version(version).ToString(CultureInfo.InvariantCulture)}";
|
||||
$"thing:composition:{ThingDomain.Present(publisher, "Publisher")}.{ThingDomain.Segment(name)}.v{ThingDomain.Version(version).ToString(CultureInfo.InvariantCulture)}";
|
||||
|
||||
public static string InvocationKey(string invocationId) => $"thing:invocation:{invocationId}";
|
||||
public static string InvocationKey(string invocationId) => $"thing:invocation:{ThingDomain.Present(invocationId, "InvocationId")}";
|
||||
|
||||
public static string ResultKey(string invocationId, int seq) =>
|
||||
$"thing:result:{invocationId}.{ThingDomain.Seq(seq).ToString(CultureInfo.InvariantCulture)}";
|
||||
$"thing:result:{ThingDomain.Present(invocationId, "InvocationId")}.{ThingDomain.Seq(seq).ToString(CultureInfo.InvariantCulture)}";
|
||||
|
||||
public static string AssetCatalogKey(string providerId, string catalogId) =>
|
||||
$"thing:asset_catalog:{providerId}.{ThingDomain.Segment(catalogId)}";
|
||||
$"thing:asset_catalog:{ThingDomain.Present(providerId, "ProviderId")}.{ThingDomain.Segment(catalogId)}";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,15 +29,15 @@ public sealed class BoxNode : Node
|
|||
[Key(1)] public BoxStyle Style { get; }
|
||||
[Key(2)] public Paint Paint { get; }
|
||||
[Key(3)] public Role Role { get; }
|
||||
[Key(4)] public List<Node> Children { get; }
|
||||
[Key(4)] public IReadOnlyList<Node> Children { get; }
|
||||
|
||||
public BoxNode(NodeCommon common, BoxStyle style, Paint paint, Role role, List<Node> children)
|
||||
public BoxNode(NodeCommon common, BoxStyle style, Paint paint, Role role, IReadOnlyList<Node> children)
|
||||
{
|
||||
Common = common;
|
||||
Style = style;
|
||||
Paint = paint;
|
||||
Role = role;
|
||||
Children = children;
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Style = ThingDomain.Present(style, "Style");
|
||||
Paint = ThingDomain.Present(paint, "Paint");
|
||||
Role = ThingDomain.Present(role, "Role");
|
||||
Children = ThingDomain.Items(children, "Children");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,9 +53,9 @@ public sealed class TextNode : Node
|
|||
|
||||
public TextNode(NodeCommon common, TextSource content, string styleToken, bool wrap, int? maxLines, bool selectable)
|
||||
{
|
||||
Common = common;
|
||||
Content = content;
|
||||
StyleToken = styleToken;
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Content = ThingDomain.Present(content, "Content");
|
||||
StyleToken = ThingDomain.Present(styleToken, "StyleToken");
|
||||
Wrap = wrap;
|
||||
MaxLines = ThingDomain.Count(maxLines);
|
||||
Selectable = selectable;
|
||||
|
|
@ -73,11 +73,11 @@ public sealed class ImageNode : Node
|
|||
|
||||
public ImageNode(NodeCommon common, string catalog, string asset, ImageFit fit, TextSource alt)
|
||||
{
|
||||
Common = common;
|
||||
Catalog = catalog;
|
||||
Asset = asset;
|
||||
Fit = fit;
|
||||
Alt = alt;
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Catalog = ThingDomain.Present(catalog, "Catalog");
|
||||
Asset = ThingDomain.Present(asset, "Asset");
|
||||
Fit = ThingDomain.Present(fit, "Fit");
|
||||
Alt = ThingDomain.Present(alt, "Alt");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,16 +85,16 @@ public sealed class ImageNode : Node
|
|||
public sealed class ButtonNode : Node
|
||||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public List<Node> Content { get; }
|
||||
[Key(1)] public IReadOnlyList<Node> Content { get; }
|
||||
[Key(2)] public CommandRef OnActivate { get; }
|
||||
[Key(3)] public BoolSource Enabled { get; }
|
||||
|
||||
public ButtonNode(NodeCommon common, List<Node> content, CommandRef onActivate, BoolSource enabled)
|
||||
public ButtonNode(NodeCommon common, IReadOnlyList<Node> content, CommandRef onActivate, BoolSource enabled)
|
||||
{
|
||||
Common = common;
|
||||
Content = content;
|
||||
OnActivate = onActivate;
|
||||
Enabled = enabled;
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Content = ThingDomain.Items(content, "Content");
|
||||
OnActivate = ThingDomain.Present(onActivate, "OnActivate");
|
||||
Enabled = ThingDomain.Present(enabled, "Enabled");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,9 +107,9 @@ public sealed class ToggleNode : Node
|
|||
|
||||
public ToggleNode(NodeCommon common, BoolSource @checked, CommandRef onChange)
|
||||
{
|
||||
Common = common;
|
||||
Checked = @checked;
|
||||
OnChange = onChange;
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Checked = ThingDomain.Present(@checked, "Checked");
|
||||
OnChange = ThingDomain.Present(onChange, "OnChange");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,12 +125,12 @@ public sealed class SliderNode : Node
|
|||
|
||||
public SliderNode(NodeCommon common, double min, double max, double step, NumberSource value, CommandRef commit)
|
||||
{
|
||||
Common = common;
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Min = min;
|
||||
Max = max;
|
||||
Step = step;
|
||||
Value = value;
|
||||
Commit = commit;
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
Commit = ThingDomain.Present(commit, "Commit");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -139,19 +139,19 @@ public sealed class TextInputNode : Node
|
|||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public bool Multiline { get; }
|
||||
[Key(2)] public InputKind Kind { get; }
|
||||
[Key(2)] public InputKind InputKind { get; }
|
||||
[Key(3)] public TextSource Placeholder { get; }
|
||||
[Key(4)] public TextSource Initial { get; }
|
||||
[Key(5)] public CommandRef Submit { get; }
|
||||
|
||||
public TextInputNode(NodeCommon common, bool multiline, InputKind kind, TextSource placeholder, TextSource initial, CommandRef submit)
|
||||
public TextInputNode(NodeCommon common, bool multiline, InputKind inputKind, TextSource placeholder, TextSource initial, CommandRef submit)
|
||||
{
|
||||
Common = common;
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Multiline = multiline;
|
||||
Kind = kind;
|
||||
Placeholder = placeholder;
|
||||
Initial = initial;
|
||||
Submit = submit;
|
||||
InputKind = ThingDomain.Present(inputKind, "InputKind");
|
||||
Placeholder = ThingDomain.Present(placeholder, "Placeholder");
|
||||
Initial = ThingDomain.Present(initial, "Initial");
|
||||
Submit = ThingDomain.Present(submit, "Submit");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,10 +165,10 @@ public sealed class SelectNode : Node
|
|||
|
||||
public SelectNode(NodeCommon common, OptionList options, Bind value, CommandRef onChange)
|
||||
{
|
||||
Common = common;
|
||||
Options = options;
|
||||
Value = value;
|
||||
OnChange = onChange;
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Options = ThingDomain.Present(options, "Options");
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
OnChange = ThingDomain.Present(onChange, "OnChange");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -182,10 +182,10 @@ public sealed class RepeatNode : Node
|
|||
|
||||
public RepeatNode(NodeCommon common, CollectionSource collection, MemberPath keyMember, Node template)
|
||||
{
|
||||
Common = common;
|
||||
Collection = collection;
|
||||
KeyMember = keyMember;
|
||||
Template = template;
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Collection = ThingDomain.Present(collection, "Collection");
|
||||
KeyMember = ThingDomain.Present(keyMember, "KeyMember");
|
||||
Template = ThingDomain.Present(template, "Template");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,15 +196,15 @@ public sealed class OverlayNode : Node
|
|||
[Key(1)] public Anchor Anchor { get; }
|
||||
[Key(2)] public bool Modal { get; }
|
||||
[Key(3)] public CommandRef Dismiss { get; }
|
||||
[Key(4)] public List<Node> Children { get; }
|
||||
[Key(4)] public IReadOnlyList<Node> Children { get; }
|
||||
|
||||
public OverlayNode(NodeCommon common, Anchor anchor, bool modal, CommandRef dismiss, List<Node> children)
|
||||
public OverlayNode(NodeCommon common, Anchor anchor, bool modal, CommandRef dismiss, IReadOnlyList<Node> children)
|
||||
{
|
||||
Common = common;
|
||||
Anchor = anchor;
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Anchor = ThingDomain.Present(anchor, "Anchor");
|
||||
Modal = modal;
|
||||
Dismiss = dismiss;
|
||||
Children = children;
|
||||
Dismiss = ThingDomain.Present(dismiss, "Dismiss");
|
||||
Children = ThingDomain.Items(children, "Children");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -221,9 +221,9 @@ public sealed class PlotNode : Node
|
|||
|
||||
public PlotNode(NodeCommon common, Bind series, PlotMark mark, double? xMin, double? xMax, double? yMin, double? yMax)
|
||||
{
|
||||
Common = common;
|
||||
Series = series;
|
||||
Mark = mark;
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Series = ThingDomain.Present(series, "Series");
|
||||
Mark = ThingDomain.Present(mark, "Mark");
|
||||
XMin = xMin;
|
||||
XMax = xMax;
|
||||
YMin = yMin;
|
||||
|
|
@ -242,9 +242,9 @@ public sealed class EmbedNode : Node
|
|||
|
||||
public EmbedNode(NodeCommon common, string slotKind, string slotId, double width, double height)
|
||||
{
|
||||
Common = common;
|
||||
SlotKind = slotKind;
|
||||
SlotId = slotId;
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
SlotKind = ThingDomain.Present(slotKind, "SlotKind");
|
||||
SlotId = ThingDomain.Present(slotId, "SlotId");
|
||||
Width = width;
|
||||
Height = height;
|
||||
}
|
||||
|
|
@ -255,13 +255,13 @@ public sealed class UseNode : Node
|
|||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public CompositionRef Composition { get; }
|
||||
[Key(2)] public List<Arg> Args { get; }
|
||||
[Key(2)] public IReadOnlyList<Arg> Args { get; }
|
||||
|
||||
public UseNode(NodeCommon common, CompositionRef composition, List<Arg> args)
|
||||
public UseNode(NodeCommon common, CompositionRef composition, IReadOnlyList<Arg> args)
|
||||
{
|
||||
Common = common;
|
||||
Composition = composition;
|
||||
Args = args;
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Composition = ThingDomain.Present(composition, "Composition");
|
||||
Args = ThingDomain.Items(args, "Args");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -345,11 +345,11 @@ public abstract class OptionList
|
|||
[MessagePackObject]
|
||||
public sealed class LiteralOptions : OptionList
|
||||
{
|
||||
[Key(0)] public List<SelectOption> Options { get; }
|
||||
[Key(0)] public IReadOnlyList<SelectOption> Options { get; }
|
||||
|
||||
public LiteralOptions(List<SelectOption> options)
|
||||
public LiteralOptions(IReadOnlyList<SelectOption> options)
|
||||
{
|
||||
Options = options;
|
||||
Options = ThingDomain.Items(options, "Options");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -362,9 +362,9 @@ public sealed class BoundOptions : OptionList
|
|||
|
||||
public BoundOptions(CollectionSource collection, MemberPath valueMember, MemberPath labelMember)
|
||||
{
|
||||
Collection = collection;
|
||||
ValueMember = valueMember;
|
||||
LabelMember = labelMember;
|
||||
Collection = ThingDomain.Present(collection, "Collection");
|
||||
ValueMember = ThingDomain.Present(valueMember, "ValueMember");
|
||||
LabelMember = ThingDomain.Present(labelMember, "LabelMember");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -391,7 +391,7 @@ public sealed class AnchorNode : Anchor
|
|||
|
||||
public AnchorNode(string node)
|
||||
{
|
||||
Node = node;
|
||||
Node = ThingDomain.Present(node, "Node");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -449,7 +449,7 @@ public sealed class TextArg : ArgValue
|
|||
|
||||
public TextArg(string value)
|
||||
{
|
||||
Value = value;
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -484,8 +484,8 @@ public sealed class ArgValueBind : ArgValue
|
|||
|
||||
public ArgValueBind(StateSource source, MemberPath member, Format? format)
|
||||
{
|
||||
Source = source;
|
||||
Member = member;
|
||||
Source = ThingDomain.Present(source, "Source");
|
||||
Member = ThingDomain.Present(member, "Member");
|
||||
Format = format;
|
||||
}
|
||||
}
|
||||
|
|
@ -497,7 +497,7 @@ public sealed class CollectionArg : ArgValue
|
|||
|
||||
public CollectionArg(CollectionSource source)
|
||||
{
|
||||
Source = source;
|
||||
Source = ThingDomain.Present(source, "Source");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -508,18 +508,18 @@ public sealed class CommandArg : ArgValue
|
|||
|
||||
public CommandArg(CommandRef command)
|
||||
{
|
||||
Command = command;
|
||||
Command = ThingDomain.Present(command, "Command");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class SlotArg : ArgValue
|
||||
{
|
||||
[Key(0)] public List<Node> Nodes { get; }
|
||||
[Key(0)] public IReadOnlyList<Node> Nodes { get; }
|
||||
|
||||
public SlotArg(List<Node> nodes)
|
||||
public SlotArg(IReadOnlyList<Node> nodes)
|
||||
{
|
||||
Nodes = nodes;
|
||||
Nodes = ThingDomain.Items(nodes, "Nodes");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -607,8 +607,8 @@ public sealed class NodeCommon
|
|||
|
||||
public NodeCommon(string id, BoolSource visible, TextSource? a11yLabel)
|
||||
{
|
||||
Id = id;
|
||||
Visible = visible;
|
||||
Id = ThingDomain.Present(id, "Id");
|
||||
Visible = ThingDomain.Present(visible, "Visible");
|
||||
A11yLabel = a11yLabel;
|
||||
}
|
||||
}
|
||||
|
|
@ -621,8 +621,8 @@ public sealed class SelectOption
|
|||
|
||||
public SelectOption(string value, string label)
|
||||
{
|
||||
Value = value;
|
||||
Label = label;
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
Label = ThingDomain.Present(label, "Label");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -635,7 +635,7 @@ public sealed class CompositionRef
|
|||
|
||||
public CompositionRef(string publisher, string name, long version)
|
||||
{
|
||||
Publisher = publisher;
|
||||
Publisher = ThingDomain.Present(publisher, "Publisher");
|
||||
Name = ThingDomain.Segment(name);
|
||||
Version = ThingDomain.Version(version);
|
||||
}
|
||||
|
|
@ -649,8 +649,8 @@ public sealed class Arg
|
|||
|
||||
public Arg(string name, ArgValue value)
|
||||
{
|
||||
Name = name;
|
||||
Value = value;
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -662,8 +662,8 @@ public sealed class ParamDecl
|
|||
|
||||
public ParamDecl(string name, ParamKind kind)
|
||||
{
|
||||
Name = name;
|
||||
Kind = kind;
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
InputKind = ThingDomain.Present(inputKind, "InputKind");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -676,16 +676,16 @@ public sealed class SurfaceDocument
|
|||
[Key(2)] public long Version { get; }
|
||||
[Key(3)] public string Title { get; }
|
||||
[Key(4)] public Node Root { get; }
|
||||
[Key(5)] public List<CommandDecl> Commands { get; }
|
||||
[Key(5)] public IReadOnlyList<CommandDecl> Commands { get; }
|
||||
|
||||
public SurfaceDocument(string providerId, string label, long version, string title, Node root, List<CommandDecl> commands)
|
||||
public SurfaceDocument(string providerId, string label, long version, string title, Node root, IReadOnlyList<CommandDecl> commands)
|
||||
{
|
||||
ProviderId = providerId;
|
||||
ProviderId = ThingDomain.Present(providerId, "ProviderId");
|
||||
Label = ThingDomain.Segment(label);
|
||||
Version = ThingDomain.Version(version);
|
||||
Title = title;
|
||||
Root = root;
|
||||
Commands = commands;
|
||||
Title = ThingDomain.Present(title, "Title");
|
||||
Root = ThingDomain.Present(root, "Root");
|
||||
Commands = ThingDomain.Items(commands, "Commands");
|
||||
}
|
||||
|
||||
[IgnoreMember] public string RecordKey => ThingKeys.SurfaceKey(ProviderId, Label);
|
||||
|
|
@ -698,16 +698,16 @@ public sealed class CompositionDocument
|
|||
[Key(0)] public string Publisher { get; }
|
||||
[Key(1)] public string Name { get; }
|
||||
[Key(2)] public long Version { get; }
|
||||
[Key(3)] public List<ParamDecl> Params { get; }
|
||||
[Key(3)] public IReadOnlyList<ParamDecl> Params { get; }
|
||||
[Key(4)] public Node Body { get; }
|
||||
|
||||
public CompositionDocument(string publisher, string name, long version, List<ParamDecl> @params, Node body)
|
||||
public CompositionDocument(string publisher, string name, long version, IReadOnlyList<ParamDecl> @params, Node body)
|
||||
{
|
||||
Publisher = publisher;
|
||||
Publisher = ThingDomain.Present(publisher, "Publisher");
|
||||
Name = ThingDomain.Segment(name);
|
||||
Version = ThingDomain.Version(version);
|
||||
Params = @params;
|
||||
Body = body;
|
||||
Params = ThingDomain.Items(@params, "Params");
|
||||
Body = ThingDomain.Present(body, "Body");
|
||||
}
|
||||
|
||||
[IgnoreMember] public string RecordKey => ThingKeys.CompositionKey(Publisher, Name, Version);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ public sealed class PendingOutcome : Outcome
|
|||
|
||||
public PendingOutcome(string message)
|
||||
{
|
||||
Message = message;
|
||||
Message = ThingDomain.Present(message, "Message");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ public sealed class DeniedOutcome : Outcome
|
|||
|
||||
public DeniedOutcome(string reason, string? node)
|
||||
{
|
||||
Reason = reason;
|
||||
Reason = ThingDomain.Present(reason, "Reason");
|
||||
Node = node;
|
||||
}
|
||||
}
|
||||
|
|
@ -59,7 +59,7 @@ public sealed class FailedOutcome : Outcome
|
|||
|
||||
public FailedOutcome(string reason)
|
||||
{
|
||||
Reason = reason;
|
||||
Reason = ThingDomain.Present(reason, "Reason");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ public sealed class OpenSurfaceEffect : Effect
|
|||
|
||||
public OpenSurfaceEffect(string key)
|
||||
{
|
||||
Key = key;
|
||||
Key = ThingDomain.Present(key, "Key");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,8 +89,8 @@ public sealed class OpenExternalEffect : Effect
|
|||
|
||||
public OpenExternalEffect(string url, CommandRef resume)
|
||||
{
|
||||
Url = url;
|
||||
Resume = resume;
|
||||
Url = ThingDomain.Present(url, "Url");
|
||||
Resume = ThingDomain.Present(resume, "Resume");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,22 +111,22 @@ public sealed class CommandResult
|
|||
[SerializationConstructor]
|
||||
private CommandResult(string invocationId, int seq, Outcome outcome)
|
||||
{
|
||||
InvocationId = invocationId;
|
||||
InvocationId = ThingDomain.Present(invocationId, "InvocationId");
|
||||
Seq = ThingDomain.Seq(seq);
|
||||
Outcome = outcome;
|
||||
Outcome = ThingDomain.Present(outcome, "Outcome");
|
||||
}
|
||||
|
||||
[IgnoreMember] public string RecordKey => ThingKeys.ResultKey(InvocationId, Seq);
|
||||
|
||||
public static CommandResult Pending(CommandInvocation invocation, int seq, string message) =>
|
||||
new(invocation.InvocationId, seq, new PendingOutcome(message));
|
||||
new(ThingDomain.Present(invocation, "Invocation").InvocationId, seq, new PendingOutcome(message));
|
||||
|
||||
public static CommandResult Completed(CommandInvocation invocation, int seq, long? sourceVersion, bool clearDrafts, Effect? effect) =>
|
||||
new(invocation.InvocationId, seq, new CompletedOutcome(sourceVersion, clearDrafts, effect));
|
||||
new(ThingDomain.Present(invocation, "Invocation").InvocationId, seq, new CompletedOutcome(sourceVersion, clearDrafts, effect));
|
||||
|
||||
public static CommandResult Denied(CommandInvocation invocation, int seq, string reason, string? node) =>
|
||||
new(invocation.InvocationId, seq, new DeniedOutcome(reason, node));
|
||||
new(ThingDomain.Present(invocation, "Invocation").InvocationId, seq, new DeniedOutcome(reason, node));
|
||||
|
||||
public static CommandResult Failed(CommandInvocation invocation, int seq, string reason) =>
|
||||
new(invocation.InvocationId, seq, new FailedOutcome(reason));
|
||||
new(ThingDomain.Present(invocation, "Invocation").InvocationId, seq, new FailedOutcome(reason));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public sealed class TextLiteral : TextSource
|
|||
|
||||
public TextLiteral(string value)
|
||||
{
|
||||
Value = value;
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -32,8 +32,8 @@ public sealed class TextSourceBound : TextSource
|
|||
|
||||
public TextSourceBound(StateSource source, MemberPath member, Format? format)
|
||||
{
|
||||
Source = source;
|
||||
Member = member;
|
||||
Source = ThingDomain.Present(source, "Source");
|
||||
Member = ThingDomain.Present(member, "Member");
|
||||
Format = format;
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ public sealed class TextSourceParam : TextSource
|
|||
|
||||
public TextSourceParam(string name)
|
||||
{
|
||||
Name = name;
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,8 +77,8 @@ public sealed class NumberSourceBound : NumberSource
|
|||
|
||||
public NumberSourceBound(StateSource source, MemberPath member, Format? format)
|
||||
{
|
||||
Source = source;
|
||||
Member = member;
|
||||
Source = ThingDomain.Present(source, "Source");
|
||||
Member = ThingDomain.Present(member, "Member");
|
||||
Format = format;
|
||||
}
|
||||
}
|
||||
|
|
@ -90,7 +90,7 @@ public sealed class NumberSourceParam : NumberSource
|
|||
|
||||
public NumberSourceParam(string name)
|
||||
{
|
||||
Name = name;
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,8 +122,8 @@ public sealed class BoolSourceBound : BoolSource
|
|||
|
||||
public BoolSourceBound(StateSource source, MemberPath member, Format? format)
|
||||
{
|
||||
Source = source;
|
||||
Member = member;
|
||||
Source = ThingDomain.Present(source, "Source");
|
||||
Member = ThingDomain.Present(member, "Member");
|
||||
Format = format;
|
||||
}
|
||||
}
|
||||
|
|
@ -135,7 +135,7 @@ public sealed class BoolSourceParam : BoolSource
|
|||
|
||||
public BoolSourceParam(string name)
|
||||
{
|
||||
Name = name;
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,8 +157,8 @@ public sealed class StateRef : StateSource
|
|||
public StateRef(string? provider, string schema, string key)
|
||||
{
|
||||
Provider = provider;
|
||||
Schema = schema;
|
||||
Key = key;
|
||||
Schema = ThingDomain.Present(schema, "Schema");
|
||||
Key = ThingDomain.Present(key, "Key");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,7 +184,7 @@ public sealed class MemberName : MemberStep
|
|||
|
||||
public MemberName(string name)
|
||||
{
|
||||
Name = name;
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -249,8 +249,8 @@ public sealed class AffixFormat : Format
|
|||
|
||||
public AffixFormat(string prefix, string suffix)
|
||||
{
|
||||
Prefix = prefix;
|
||||
Suffix = suffix;
|
||||
Prefix = ThingDomain.Present(prefix, "Prefix");
|
||||
Suffix = ThingDomain.Present(suffix, "Suffix");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -272,8 +272,8 @@ public sealed class RecordsSource : CollectionSource
|
|||
public RecordsSource(string? provider, string schema, string keyPrefix)
|
||||
{
|
||||
Provider = provider;
|
||||
Schema = schema;
|
||||
KeyPrefix = keyPrefix;
|
||||
Schema = ThingDomain.Present(schema, "Schema");
|
||||
KeyPrefix = ThingDomain.Present(keyPrefix, "KeyPrefix");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,8 +286,8 @@ public sealed class CollectionSourceMember : CollectionSource
|
|||
|
||||
public CollectionSourceMember(StateSource source, MemberPath member, Format? format)
|
||||
{
|
||||
Source = source;
|
||||
Member = member;
|
||||
Source = ThingDomain.Present(source, "Source");
|
||||
Member = ThingDomain.Present(member, "Member");
|
||||
Format = format;
|
||||
}
|
||||
}
|
||||
|
|
@ -299,7 +299,7 @@ public sealed class CollectionSourceParam : CollectionSource
|
|||
|
||||
public CollectionSourceParam(string name)
|
||||
{
|
||||
Name = name;
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -312,8 +312,8 @@ public sealed class Bind
|
|||
|
||||
public Bind(StateSource source, MemberPath member, Format? format)
|
||||
{
|
||||
Source = source;
|
||||
Member = member;
|
||||
Source = ThingDomain.Present(source, "Source");
|
||||
Member = ThingDomain.Present(member, "Member");
|
||||
Format = format;
|
||||
}
|
||||
}
|
||||
|
|
@ -321,10 +321,10 @@ public sealed class Bind
|
|||
[MessagePackObject]
|
||||
public sealed class MemberPath
|
||||
{
|
||||
[Key(0)] public List<MemberStep> Steps { get; }
|
||||
[Key(0)] public IReadOnlyList<MemberStep> Steps { get; }
|
||||
|
||||
public MemberPath(List<MemberStep> steps)
|
||||
public MemberPath(IReadOnlyList<MemberStep> steps)
|
||||
{
|
||||
Steps = steps;
|
||||
Steps = ThingDomain.Items(steps, "Steps");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -404,7 +404,7 @@ public sealed class ThemeToken : ColorRef
|
|||
|
||||
public ThemeToken(string name)
|
||||
{
|
||||
Name = name;
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -508,8 +508,8 @@ public sealed class Size2
|
|||
|
||||
public Size2(Dimension width, Dimension height)
|
||||
{
|
||||
Width = width;
|
||||
Height = height;
|
||||
Width = ThingDomain.Present(width, "Width");
|
||||
Height = ThingDomain.Present(height, "Height");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -523,10 +523,10 @@ public sealed class Edges
|
|||
|
||||
public Edges(Dimension top, Dimension right, Dimension bottom, Dimension left)
|
||||
{
|
||||
Top = top;
|
||||
Right = right;
|
||||
Bottom = bottom;
|
||||
Left = left;
|
||||
Top = ThingDomain.Present(top, "Top");
|
||||
Right = ThingDomain.Present(right, "Right");
|
||||
Bottom = ThingDomain.Present(bottom, "Bottom");
|
||||
Left = ThingDomain.Present(left, "Left");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -552,32 +552,32 @@ public sealed class BoxStyle
|
|||
[Key(16)] public double Grow { get; }
|
||||
[Key(17)] public double Shrink { get; }
|
||||
[Key(18)] public Dimension Basis { get; }
|
||||
[Key(19)] public List<Dimension> GridColumns { get; }
|
||||
[Key(20)] public List<Dimension> GridRows { get; }
|
||||
[Key(19)] public IReadOnlyList<Dimension> GridColumns { get; }
|
||||
[Key(20)] public IReadOnlyList<Dimension> GridRows { get; }
|
||||
|
||||
public BoxStyle(Display display, Direction direction, Wrap wrap, Size2 gap, Edges padding, Edges margin, Size2 size, Size2 minSize, Size2 maxSize, Position position, Edges inset, Overflow overflow, ItemAlign alignItems, ItemAlign alignSelf, ContentAlign alignContent, ContentAlign justifyContent, double grow, double shrink, Dimension basis, List<Dimension> gridColumns, List<Dimension> gridRows)
|
||||
public BoxStyle(Display display, Direction direction, Wrap wrap, Size2 gap, Edges padding, Edges margin, Size2 size, Size2 minSize, Size2 maxSize, Position position, Edges inset, Overflow overflow, ItemAlign alignItems, ItemAlign alignSelf, ContentAlign alignContent, ContentAlign justifyContent, double grow, double shrink, Dimension basis, IReadOnlyList<Dimension> gridColumns, IReadOnlyList<Dimension> gridRows)
|
||||
{
|
||||
Display = display;
|
||||
Direction = direction;
|
||||
Wrap = wrap;
|
||||
Gap = gap;
|
||||
Padding = padding;
|
||||
Margin = margin;
|
||||
Size = size;
|
||||
MinSize = minSize;
|
||||
MaxSize = maxSize;
|
||||
Position = position;
|
||||
Inset = inset;
|
||||
Overflow = overflow;
|
||||
AlignItems = alignItems;
|
||||
AlignSelf = alignSelf;
|
||||
AlignContent = alignContent;
|
||||
JustifyContent = justifyContent;
|
||||
Display = ThingDomain.Present(display, "Display");
|
||||
Direction = ThingDomain.Present(direction, "Direction");
|
||||
Wrap = ThingDomain.Present(wrap, "Wrap");
|
||||
Gap = ThingDomain.Present(gap, "Gap");
|
||||
Padding = ThingDomain.Present(padding, "Padding");
|
||||
Margin = ThingDomain.Present(margin, "Margin");
|
||||
Size = ThingDomain.Present(size, "Size");
|
||||
MinSize = ThingDomain.Present(minSize, "MinSize");
|
||||
MaxSize = ThingDomain.Present(maxSize, "MaxSize");
|
||||
Position = ThingDomain.Present(position, "Position");
|
||||
Inset = ThingDomain.Present(inset, "Inset");
|
||||
Overflow = ThingDomain.Present(overflow, "Overflow");
|
||||
AlignItems = ThingDomain.Present(alignItems, "AlignItems");
|
||||
AlignSelf = ThingDomain.Present(alignSelf, "AlignSelf");
|
||||
AlignContent = ThingDomain.Present(alignContent, "AlignContent");
|
||||
JustifyContent = ThingDomain.Present(justifyContent, "JustifyContent");
|
||||
Grow = grow;
|
||||
Shrink = shrink;
|
||||
Basis = basis;
|
||||
GridColumns = gridColumns;
|
||||
GridRows = gridRows;
|
||||
Basis = ThingDomain.Present(basis, "Basis");
|
||||
GridColumns = ThingDomain.Items(gridColumns, "GridColumns");
|
||||
GridRows = ThingDomain.Items(gridRows, "GridRows");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -589,7 +589,7 @@ public sealed class Border
|
|||
|
||||
public Border(ColorRef color, double width)
|
||||
{
|
||||
Color = color;
|
||||
Color = ThingDomain.Present(color, "Color");
|
||||
Width = width;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
80
tests/GameCult.Thing.Contract.Tests/Doors.cs
Normal file
80
tests/GameCult.Thing.Contract.Tests/Doors.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
using System.Reflection;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Every way a caller can build a contract value without decoding: each public constructor, each public static factory
|
||||
/// returning its own type, and the key builders. Arguments are built through the same public constructors a consumer
|
||||
/// would use, so a test's inputs never come from a private door.
|
||||
/// </summary>
|
||||
internal static class Doors
|
||||
{
|
||||
private static readonly Assembly Package = typeof(CommandResult).Assembly;
|
||||
|
||||
public sealed record Door(string Name, MethodBase Method)
|
||||
{
|
||||
public ParameterInfo[] Parameters => Method.GetParameters();
|
||||
|
||||
public object? Call(object?[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Method is ConstructorInfo ctor ? ctor.Invoke(args) : Method.Invoke(null, args);
|
||||
}
|
||||
catch (TargetInvocationException error)
|
||||
{
|
||||
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(error.InnerException!).Throw();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public object?[] ValidArgs() => Parameters.Select(p => Make(p.ParameterType)).ToArray();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<Door> All()
|
||||
{
|
||||
var doors = new List<Door>();
|
||||
foreach (var type in Package.GetExportedTypes().Where(t => !t.IsAbstract || t.IsSealed))
|
||||
{
|
||||
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.Instance))
|
||||
doors.Add(new Door($"new {type.Name}", ctor));
|
||||
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (type.Name == nameof(ThingDomain)) continue;
|
||||
if (method.ReturnType == type || type.Name == nameof(ThingKeys)) doors.Add(new Door($"{type.Name}.{method.Name}", method));
|
||||
}
|
||||
}
|
||||
|
||||
return doors;
|
||||
}
|
||||
|
||||
/// <summary>A valid value of <paramref name="type"/>: the first union arm of a union, the first public constructor otherwise.</summary>
|
||||
public static object Make(Type type, int depth = 0)
|
||||
{
|
||||
if (depth > 40) throw new InvalidOperationException($"no finite valid value of {type.Name}");
|
||||
var underlying = Nullable.GetUnderlyingType(type);
|
||||
if (underlying != null) return Make(underlying, depth);
|
||||
if (type == typeof(string)) return "x";
|
||||
if (type == typeof(int)) return 1;
|
||||
if (type == typeof(long)) return 1L;
|
||||
if (type == typeof(double)) return 1.0;
|
||||
if (type == typeof(bool)) return true;
|
||||
if (type == typeof(byte)) return (byte)1;
|
||||
if (type == typeof(byte[])) return new byte[] { 1 };
|
||||
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IReadOnlyList<>))
|
||||
return MakeList(type.GetGenericArguments()[0], 0, depth);
|
||||
var arms = type.GetCustomAttributes<UnionAttribute>(false).ToList();
|
||||
if (type.IsAbstract && arms.Count > 0) return Make(arms[0].SubType, depth + 1);
|
||||
var ctor = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance).First();
|
||||
return ctor.Invoke(ctor.GetParameters().Select(p => Make(p.ParameterType, depth + 1)).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>A mutable list of <paramref name="count"/> valid elements, as a consumer holds one before constructing.</summary>
|
||||
public static System.Collections.IList MakeList(Type element, int count, int depth = 0)
|
||||
{
|
||||
var list = (System.Collections.IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(element))!;
|
||||
for (var i = 0; i < count; i++) list.Add(Make(element, depth + 1));
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
|
@ -28,5 +28,8 @@
|
|||
<None Include="..\..\crates\thing-contract\tests\goldens\*"
|
||||
Link="goldens\%(Filename)%(Extension)"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\tests\thing-contract-parity\*"
|
||||
Link="parity\%(Filename)%(Extension)"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ public sealed class GoldenTests
|
|||
|
||||
// ---- the store ----
|
||||
|
||||
// surface.cc's catalog is written by cultcache-rs with empty members, so this proves bytes and keys only; the type
|
||||
// names are NameSetTests.
|
||||
[Test]
|
||||
public void StoreRead()
|
||||
{
|
||||
|
|
|
|||
18
tests/GameCult.Thing.Contract.Tests/InputKindNameTests.cs
Normal file
18
tests/GameCult.Thing.Contract.Tests/InputKindNameTests.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
using System.Reflection;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>One name in every runtime: Rust input_kind, C# InputKind, TypeScript inputKind. The wire is the slot key, 2.</summary>
|
||||
public sealed class InputKindNameTests
|
||||
{
|
||||
[Test]
|
||||
public void TextInputNodeSlotTwoIsInputKindAndThereIsNoKind()
|
||||
{
|
||||
var properties = typeof(TextInputNode).GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
var slotTwo = properties.Single(p => p.GetCustomAttribute<KeyAttribute>()?.IntKey == 2);
|
||||
Assert.That((slotTwo.Name, slotTwo.PropertyType), Is.EqualTo(("InputKind", typeof(InputKind))));
|
||||
Assert.That(properties.Select(p => p.Name), Does.Not.Contain("Kind"));
|
||||
Assert.That(typeof(TextInputNode).GetConstructors().Single().GetParameters()[2].Name, Is.EqualTo("inputKind"));
|
||||
}
|
||||
}
|
||||
75
tests/GameCult.Thing.Contract.Tests/NilPositionTests.cs
Normal file
75
tests/GameCult.Thing.Contract.Tests/NilPositionTests.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The nil vector, written by the Rust crate and read here (goldens/nil-positions.tsv): for every array element of every
|
||||
/// value golden, the golden with that element replaced by nil, and whether Rust's decode door accepted it. A slot is
|
||||
/// required exactly when its Rust type is not optional, so this package keeps no list of required slots: it is held to
|
||||
/// every row.
|
||||
/// </summary>
|
||||
public sealed class NilPositionTests
|
||||
{
|
||||
private sealed record Row(string Golden, string Path, int Start, int End, bool Accept);
|
||||
|
||||
private static readonly Dictionary<string, Type> Kinds = new()
|
||||
{
|
||||
["surface-all-arms"] = typeof(SurfaceDocument),
|
||||
["composition-all-params"] = typeof(CompositionDocument),
|
||||
["invocation-all-inputs"] = typeof(CommandInvocation),
|
||||
["result-pending"] = typeof(CommandResult),
|
||||
["result-completed"] = typeof(CommandResult),
|
||||
["result-completed-open-surface"] = typeof(CommandResult),
|
||||
["result-completed-open-external"] = typeof(CommandResult),
|
||||
["result-denied"] = typeof(CommandResult),
|
||||
["result-denied-no-node"] = typeof(CommandResult),
|
||||
["result-failed"] = typeof(CommandResult),
|
||||
["asset-catalog"] = typeof(AssetCatalog),
|
||||
};
|
||||
|
||||
private static List<Row> Rows() =>
|
||||
File.ReadAllLines(Wire.GoldenPath("nil-positions.tsv")).Select(line =>
|
||||
{
|
||||
var cells = line.Split('\t');
|
||||
Assert.That(cells, Has.Length.EqualTo(5), line);
|
||||
Assert.That(cells[4], Is.AnyOf("ACCEPT", "REFUSE"), line);
|
||||
return new Row(cells[0], cells[1], int.Parse(cells[2]), int.Parse(cells[3]), cells[4] == "ACCEPT");
|
||||
}).ToList();
|
||||
|
||||
private static IEnumerable<string> Goldens() => Kinds.Keys;
|
||||
|
||||
[Test]
|
||||
public void TheVectorNamesEveryValueGoldenAndNothingElse()
|
||||
{
|
||||
var rows = Rows();
|
||||
Assert.That(rows.Select(r => r.Golden).Distinct().OrderBy(n => n, StringComparer.Ordinal), Is.EqualTo(Kinds.Keys.OrderBy(n => n, StringComparer.Ordinal)));
|
||||
Assert.That(rows, Has.Count.GreaterThan(1500));
|
||||
Assert.That(rows.Count(r => r.Accept), Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(Goldens))]
|
||||
public void EveryNilPositionIsDecidedAsRustDecidedIt(string golden)
|
||||
{
|
||||
var rows = Rows().Where(r => r.Golden == golden).ToList();
|
||||
Assert.That(rows, Is.Not.Empty);
|
||||
var bytes = Wire.Golden($"{golden}.bin");
|
||||
var wrong = new List<string>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var spliced = bytes[..row.Start].Concat(new byte[] { 0xC0 }).Concat(bytes[row.End..]).ToArray();
|
||||
try
|
||||
{
|
||||
var decoded = Wire.Read(Kinds[golden], spliced);
|
||||
if (!row.Accept) wrong.Add($"{row.Path}: accepted though Rust refused");
|
||||
else if (!Wire.Write(decoded).SequenceEqual(spliced)) wrong.Add($"{row.Path}: accepted but re-encodes to other bytes");
|
||||
}
|
||||
catch (MessagePackSerializationException)
|
||||
{
|
||||
if (row.Accept) wrong.Add($"{row.Path}: refused though Rust accepted");
|
||||
}
|
||||
}
|
||||
|
||||
TestContext.Progress.WriteLine($"NILROWS {golden} rows={rows.Count} accept={rows.Count(r => r.Accept)} refuse={rows.Count(r => !r.Accept)}");
|
||||
Assert.That(wrong.Take(20), Is.Empty, $"{golden}: {wrong.Count} rows differ from Rust");
|
||||
}
|
||||
}
|
||||
79
tests/GameCult.Thing.Contract.Tests/PresenceTests.cs
Normal file
79
tests/GameCult.Thing.Contract.Tests/PresenceTests.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using System.Reflection;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The constructor door: a slot the annotations make non-null refuses null, naming the slot; a slot they make nullable
|
||||
/// accepts it. The annotations mirror the Rust contract's Option types (the nil vector proves that against Rust), so no
|
||||
/// list of required slots lives here.
|
||||
/// </summary>
|
||||
public sealed class PresenceTests
|
||||
{
|
||||
private static string Slot(string parameter) => parameter.TrimStart('@');
|
||||
|
||||
[Test]
|
||||
public void EveryConstructorAndFactoryHoldsPresenceAtEveryReferenceParameter()
|
||||
{
|
||||
var failures = new List<string>();
|
||||
var required = 0;
|
||||
var optional = 0;
|
||||
var context = new NullabilityInfoContext();
|
||||
foreach (var door in Doors.All())
|
||||
{
|
||||
var parameters = door.Parameters;
|
||||
for (var index = 0; index < parameters.Length; index++)
|
||||
{
|
||||
var parameter = parameters[index];
|
||||
var isValue = parameter.ParameterType.IsValueType && Nullable.GetUnderlyingType(parameter.ParameterType) == null;
|
||||
if (isValue) continue;
|
||||
var state = context.Create(parameter).WriteState;
|
||||
var args = door.ValidArgs();
|
||||
args[index] = null;
|
||||
try
|
||||
{
|
||||
door.Call(args);
|
||||
if (state == NullabilityState.NotNull) failures.Add($"{door.Name}({parameter.Name}) accepted null");
|
||||
else optional++;
|
||||
}
|
||||
catch (ThingDomainException error)
|
||||
{
|
||||
if (state != NullabilityState.NotNull) failures.Add($"{door.Name}({parameter.Name}) refused null though nullable: {error.Message}");
|
||||
else if (!string.Equals(error.Domain, Slot(parameter.Name!), StringComparison.OrdinalIgnoreCase) && error.Domain != "Segment")
|
||||
failures.Add($"{door.Name}({parameter.Name}) named {error.Domain}");
|
||||
else required++;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
failures.Add($"{door.Name}({parameter.Name}) threw {error.GetType().Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.That(failures, Is.Empty);
|
||||
TestContext.Progress.WriteLine($"PRESENCE required={required} optional={optional}");
|
||||
Assert.That(required, Is.GreaterThan(200), "the scan reached the required slots");
|
||||
Assert.That(optional, Is.GreaterThan(15), "the scan reached the optional slots");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ARefusalNamesTheSlotAndNeverAValue()
|
||||
{
|
||||
var invocation = Builders.InvocationNamed("i");
|
||||
var pending = Assert.Throws<ThingDomainException>(() => CommandResult.Pending(invocation, 1, null!))!;
|
||||
Assert.That(pending.Domain, Is.EqualTo("Message"));
|
||||
Assert.That(pending.Message, Is.EqualTo("Message must be present"));
|
||||
var outcome = Assert.Throws<ThingDomainException>(() => CommandResult.Pending(null!, 1, Wire.Canary))!;
|
||||
Assert.That(outcome.Domain, Is.EqualTo("Invocation"));
|
||||
Assert.That(outcome.Message, Does.Not.Contain(Wire.Canary));
|
||||
var uri = Assert.Throws<ThingDomainException>(() => new AssetEntry("id", "image/png", null!, 1, 1))!;
|
||||
Assert.That(uri.Domain, Is.EqualTo("Uri"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ARefusedListNamesTheSlot()
|
||||
{
|
||||
Assert.That(Assert.Throws<ThingDomainException>(() => ThingDomain.Items<string>(null, "Captures"))!.Domain, Is.EqualTo("Captures"));
|
||||
var nilItem = Assert.Throws<ThingDomainException>(() => ThingDomain.Items<string>(new List<string> { "a", null! }, "Captures"))!;
|
||||
Assert.That((nilItem.Domain, nilItem.Bound), Is.EqualTo(("Captures", "free of nil items")));
|
||||
}
|
||||
}
|
||||
81
tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs
Normal file
81
tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using System.Collections;
|
||||
using System.Reflection;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A list a consumer can mutate after construction escapes every constructor check, and is how a cycle reaches the
|
||||
/// writer (cultlib-gaps:follow_up:cs-write-depth-before-serialize owns the writer's side). The package holds each list
|
||||
/// as a read-only copy of what was passed.
|
||||
/// </summary>
|
||||
public sealed class ReadOnlyListTests
|
||||
{
|
||||
private static bool IsMutableCollection(Type type) =>
|
||||
type != typeof(byte[]) // an opaque payload, not a tree
|
||||
&& (typeof(IList).IsAssignableFrom(type) || type.GetInterfaces().Concat(new[] { type }).Any(i =>
|
||||
i.IsGenericType && (i.GetGenericTypeDefinition() == typeof(ICollection<>) || i.GetGenericTypeDefinition() == typeof(IDictionary<,>))));
|
||||
|
||||
private static IEnumerable<(Doors.Door Door, int Index, Type Element)> ListParameters() =>
|
||||
from door in Doors.All()
|
||||
from parameter in door.Parameters.Select((p, i) => (p, i))
|
||||
where parameter.p.ParameterType.IsGenericType && parameter.p.ParameterType.GetGenericTypeDefinition() == typeof(IReadOnlyList<>)
|
||||
select (door, parameter.i, parameter.p.ParameterType.GetGenericArguments()[0]);
|
||||
|
||||
[Test]
|
||||
public void NoPublicPropertyOfAnyContractTypeIsAMutableCollection()
|
||||
{
|
||||
var offenders = new List<string>();
|
||||
var checkedProperties = 0;
|
||||
foreach (var type in typeof(CommandResult).Assembly.GetExportedTypes())
|
||||
foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
||||
{
|
||||
checkedProperties++;
|
||||
if (IsMutableCollection(property.PropertyType)) offenders.Add($"{type.Name}.{property.Name}: {property.PropertyType.Name}");
|
||||
}
|
||||
|
||||
Assert.That(offenders, Is.Empty);
|
||||
Assert.That(checkedProperties, Is.GreaterThan(300));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryListParameterIsCopiedAndTheCopyCannotGrow()
|
||||
{
|
||||
var lists = ListParameters().ToList();
|
||||
Assert.That(lists, Has.Count.GreaterThanOrEqualTo(14), "the scan reached every list slot");
|
||||
foreach (var (door, index, element) in lists)
|
||||
{
|
||||
var args = door.ValidArgs();
|
||||
var passed = Doors.MakeList(element, 1);
|
||||
args[index] = passed;
|
||||
var value = door.Call(args)!;
|
||||
var name = door.Parameters[index].Name!.TrimStart('@');
|
||||
var property = value.GetType().GetProperty(char.ToUpperInvariant(name[0]) + name[1..])!;
|
||||
var held = (IList)property.GetValue(value)!;
|
||||
Assert.That(held, Has.Count.EqualTo(1), door.Name);
|
||||
|
||||
passed.Add(Doors.Make(element));
|
||||
Assert.That(held, Has.Count.EqualTo(1), $"{door.Name}: adding to the passed list changed the value");
|
||||
Assert.That(held, Is.Not.SameAs(passed), door.Name);
|
||||
Assert.Throws<NotSupportedException>(() => held.Add(Doors.Make(element)), $"{door.Name}: the held list grew");
|
||||
Assert.Throws<NotSupportedException>(() => held[0] = Doors.Make(element), $"{door.Name}: the held list was written");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryListParameterRefusesANilItemAndANilList()
|
||||
{
|
||||
foreach (var (door, index, element) in ListParameters())
|
||||
{
|
||||
var args = door.ValidArgs();
|
||||
var withNil = Doors.MakeList(element, 1);
|
||||
withNil.Add(null);
|
||||
args[index] = withNil;
|
||||
var item = Assert.Throws<ThingDomainException>(() => door.Call(args), $"{door.Name}: nil item")!;
|
||||
Assert.That(item.Bound, Is.EqualTo("free of nil items"), door.Name);
|
||||
|
||||
args[index] = null;
|
||||
var list = Assert.Throws<ThingDomainException>(() => door.Call(args), $"{door.Name}: nil list")!;
|
||||
Assert.That(list.Bound, Is.EqualTo("present"), door.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue