From ee80246cd0f92732629c9f520f77c2f471fb7954 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 02:18:45 +0200 Subject: [PATCH] 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 --- packages/GameCult.Thing.Contract/Asset.cs | 14 +- packages/GameCult.Thing.Contract/Command.cs | 44 ++-- packages/GameCult.Thing.Contract/Domain.cs | 21 +- packages/GameCult.Thing.Contract/Keys.cs | 10 +- packages/GameCult.Thing.Contract/Node.cs | 188 +++++++++--------- packages/GameCult.Thing.Contract/Result.cs | 24 +-- packages/GameCult.Thing.Contract/Source.cs | 50 ++--- packages/GameCult.Thing.Contract/Style.cs | 60 +++--- tests/GameCult.Thing.Contract.Tests/Doors.cs | 80 ++++++++ .../GameCult.Thing.Contract.Tests.csproj | 3 + .../GoldenTests.cs | 2 + .../InputKindNameTests.cs | 18 ++ .../NilPositionTests.cs | 75 +++++++ .../PresenceTests.cs | 79 ++++++++ .../ReadOnlyListTests.cs | 81 ++++++++ 15 files changed, 553 insertions(+), 196 deletions(-) create mode 100644 tests/GameCult.Thing.Contract.Tests/Doors.cs create mode 100644 tests/GameCult.Thing.Contract.Tests/InputKindNameTests.cs create mode 100644 tests/GameCult.Thing.Contract.Tests/NilPositionTests.cs create mode 100644 tests/GameCult.Thing.Contract.Tests/PresenceTests.cs create mode 100644 tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs diff --git a/packages/GameCult.Thing.Contract/Asset.cs b/packages/GameCult.Thing.Contract/Asset.cs index db2ebd1..cc0054d 100644 --- a/packages/GameCult.Thing.Contract/Asset.cs +++ b/packages/GameCult.Thing.Contract/Asset.cs @@ -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 Assets { get; } + [Key(3)] public IReadOnlyList Assets { get; } - public AssetCatalog(string providerId, string catalogId, long version, List assets) + public AssetCatalog(string providerId, string catalogId, long version, IReadOnlyList 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); diff --git a/packages/GameCult.Thing.Contract/Command.cs b/packages/GameCult.Thing.Contract/Command.cs index 0f050ad..c4258b3 100644 --- a/packages/GameCult.Thing.Contract/Command.cs +++ b/packages/GameCult.Thing.Contract/Command.cs @@ -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 Captures { get; } + [Key(2)] public IReadOnlyList Captures { get; } [Key(3)] public string? Confirm { get; } - public CommandDecl(string id, string payloadSchema, List captures, string? confirm) + public CommandDecl(string id, string payloadSchema, IReadOnlyList 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 Captured { get; } + [Key(5)] public IReadOnlyList 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 captured, string client, string issuedAt, string? delegation) + public CommandInvocation(string invocationId, string surfaceKey, long surfaceVersion, string command, RecordPayload payload, IReadOnlyList 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; } diff --git a/packages/GameCult.Thing.Contract/Domain.cs b/packages/GameCult.Thing.Contract/Domain.cs index 573b433..ca23b16 100644 --- a/packages/GameCult.Thing.Contract/Domain.cs +++ b/packages/GameCult.Thing.Contract/Domain.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace GameCult.Thing.Contract; @@ -19,7 +20,8 @@ public sealed class ThingDomainException : Exception } /// -/// 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. /// 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; + + /// A required slot: the value, or a refusal naming the slot when it is null. + public static T Present(T? value, string slot) where T : class => + value ?? throw new ThingDomainException(slot, "present"); + + /// + /// 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. + /// + public static IReadOnlyList Items(IReadOnlyList? 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); + } } diff --git a/packages/GameCult.Thing.Contract/Keys.cs b/packages/GameCult.Thing.Contract/Keys.cs index 4ecb57a..2c4ec44 100644 --- a/packages/GameCult.Thing.Contract/Keys.cs +++ b/packages/GameCult.Thing.Contract/Keys.cs @@ -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)}"; } diff --git a/packages/GameCult.Thing.Contract/Node.cs b/packages/GameCult.Thing.Contract/Node.cs index 3d510a1..c981a82 100644 --- a/packages/GameCult.Thing.Contract/Node.cs +++ b/packages/GameCult.Thing.Contract/Node.cs @@ -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 Children { get; } + [Key(4)] public IReadOnlyList Children { get; } - public BoxNode(NodeCommon common, BoxStyle style, Paint paint, Role role, List children) + public BoxNode(NodeCommon common, BoxStyle style, Paint paint, Role role, IReadOnlyList 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 Content { get; } + [Key(1)] public IReadOnlyList Content { get; } [Key(2)] public CommandRef OnActivate { get; } [Key(3)] public BoolSource Enabled { get; } - public ButtonNode(NodeCommon common, List content, CommandRef onActivate, BoolSource enabled) + public ButtonNode(NodeCommon common, IReadOnlyList 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 Children { get; } + [Key(4)] public IReadOnlyList Children { get; } - public OverlayNode(NodeCommon common, Anchor anchor, bool modal, CommandRef dismiss, List children) + public OverlayNode(NodeCommon common, Anchor anchor, bool modal, CommandRef dismiss, IReadOnlyList 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 Args { get; } + [Key(2)] public IReadOnlyList Args { get; } - public UseNode(NodeCommon common, CompositionRef composition, List args) + public UseNode(NodeCommon common, CompositionRef composition, IReadOnlyList 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 Options { get; } + [Key(0)] public IReadOnlyList Options { get; } - public LiteralOptions(List options) + public LiteralOptions(IReadOnlyList 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 Nodes { get; } + [Key(0)] public IReadOnlyList Nodes { get; } - public SlotArg(List nodes) + public SlotArg(IReadOnlyList 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 Commands { get; } + [Key(5)] public IReadOnlyList Commands { get; } - public SurfaceDocument(string providerId, string label, long version, string title, Node root, List commands) + public SurfaceDocument(string providerId, string label, long version, string title, Node root, IReadOnlyList 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 Params { get; } + [Key(3)] public IReadOnlyList Params { get; } [Key(4)] public Node Body { get; } - public CompositionDocument(string publisher, string name, long version, List @params, Node body) + public CompositionDocument(string publisher, string name, long version, IReadOnlyList @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); diff --git a/packages/GameCult.Thing.Contract/Result.cs b/packages/GameCult.Thing.Contract/Result.cs index 7544a5f..ea0a620 100644 --- a/packages/GameCult.Thing.Contract/Result.cs +++ b/packages/GameCult.Thing.Contract/Result.cs @@ -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)); } diff --git a/packages/GameCult.Thing.Contract/Source.cs b/packages/GameCult.Thing.Contract/Source.cs index 4031fe9..deac6e1 100644 --- a/packages/GameCult.Thing.Contract/Source.cs +++ b/packages/GameCult.Thing.Contract/Source.cs @@ -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 Steps { get; } + [Key(0)] public IReadOnlyList Steps { get; } - public MemberPath(List steps) + public MemberPath(IReadOnlyList steps) { - Steps = steps; + Steps = ThingDomain.Items(steps, "Steps"); } } diff --git a/packages/GameCult.Thing.Contract/Style.cs b/packages/GameCult.Thing.Contract/Style.cs index f260bda..e37f1ca 100644 --- a/packages/GameCult.Thing.Contract/Style.cs +++ b/packages/GameCult.Thing.Contract/Style.cs @@ -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 GridColumns { get; } - [Key(20)] public List GridRows { get; } + [Key(19)] public IReadOnlyList GridColumns { get; } + [Key(20)] public IReadOnlyList 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 gridColumns, List 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 gridColumns, IReadOnlyList 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; } } diff --git a/tests/GameCult.Thing.Contract.Tests/Doors.cs b/tests/GameCult.Thing.Contract.Tests/Doors.cs new file mode 100644 index 0000000..d536ce2 --- /dev/null +++ b/tests/GameCult.Thing.Contract.Tests/Doors.cs @@ -0,0 +1,80 @@ +using System.Reflection; +using MessagePack; + +namespace GameCult.Thing.Contract.Tests; + +/// +/// 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. +/// +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 All() + { + var doors = new List(); + 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; + } + + /// A valid value of : the first union arm of a union, the first public constructor otherwise. + 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(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()); + } + + /// A mutable list of valid elements, as a consumer holds one before constructing. + 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; + } +} diff --git a/tests/GameCult.Thing.Contract.Tests/GameCult.Thing.Contract.Tests.csproj b/tests/GameCult.Thing.Contract.Tests/GameCult.Thing.Contract.Tests.csproj index 6d7164b..6d1328d 100644 --- a/tests/GameCult.Thing.Contract.Tests/GameCult.Thing.Contract.Tests.csproj +++ b/tests/GameCult.Thing.Contract.Tests/GameCult.Thing.Contract.Tests.csproj @@ -28,5 +28,8 @@ + diff --git a/tests/GameCult.Thing.Contract.Tests/GoldenTests.cs b/tests/GameCult.Thing.Contract.Tests/GoldenTests.cs index 258e54a..e71e7fb 100644 --- a/tests/GameCult.Thing.Contract.Tests/GoldenTests.cs +++ b/tests/GameCult.Thing.Contract.Tests/GoldenTests.cs @@ -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() { diff --git a/tests/GameCult.Thing.Contract.Tests/InputKindNameTests.cs b/tests/GameCult.Thing.Contract.Tests/InputKindNameTests.cs new file mode 100644 index 0000000..b7c3f06 --- /dev/null +++ b/tests/GameCult.Thing.Contract.Tests/InputKindNameTests.cs @@ -0,0 +1,18 @@ +using System.Reflection; +using MessagePack; + +namespace GameCult.Thing.Contract.Tests; + +/// One name in every runtime: Rust input_kind, C# InputKind, TypeScript inputKind. The wire is the slot key, 2. +public sealed class InputKindNameTests +{ + [Test] + public void TextInputNodeSlotTwoIsInputKindAndThereIsNoKind() + { + var properties = typeof(TextInputNode).GetProperties(BindingFlags.Public | BindingFlags.Instance); + var slotTwo = properties.Single(p => p.GetCustomAttribute()?.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")); + } +} diff --git a/tests/GameCult.Thing.Contract.Tests/NilPositionTests.cs b/tests/GameCult.Thing.Contract.Tests/NilPositionTests.cs new file mode 100644 index 0000000..5344cf4 --- /dev/null +++ b/tests/GameCult.Thing.Contract.Tests/NilPositionTests.cs @@ -0,0 +1,75 @@ +using MessagePack; + +namespace GameCult.Thing.Contract.Tests; + +/// +/// 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. +/// +public sealed class NilPositionTests +{ + private sealed record Row(string Golden, string Path, int Start, int End, bool Accept); + + private static readonly Dictionary 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 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 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(); + 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"); + } +} diff --git a/tests/GameCult.Thing.Contract.Tests/PresenceTests.cs b/tests/GameCult.Thing.Contract.Tests/PresenceTests.cs new file mode 100644 index 0000000..6f6bbc2 --- /dev/null +++ b/tests/GameCult.Thing.Contract.Tests/PresenceTests.cs @@ -0,0 +1,79 @@ +using System.Reflection; + +namespace GameCult.Thing.Contract.Tests; + +/// +/// 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. +/// +public sealed class PresenceTests +{ + private static string Slot(string parameter) => parameter.TrimStart('@'); + + [Test] + public void EveryConstructorAndFactoryHoldsPresenceAtEveryReferenceParameter() + { + var failures = new List(); + 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(() => 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(() => 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(() => new AssetEntry("id", "image/png", null!, 1, 1))!; + Assert.That(uri.Domain, Is.EqualTo("Uri")); + } + + [Test] + public void ARefusedListNamesTheSlot() + { + Assert.That(Assert.Throws(() => ThingDomain.Items(null, "Captures"))!.Domain, Is.EqualTo("Captures")); + var nilItem = Assert.Throws(() => ThingDomain.Items(new List { "a", null! }, "Captures"))!; + Assert.That((nilItem.Domain, nilItem.Bound), Is.EqualTo(("Captures", "free of nil items"))); + } +} diff --git a/tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs b/tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs new file mode 100644 index 0000000..68b37e6 --- /dev/null +++ b/tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs @@ -0,0 +1,81 @@ +using System.Collections; +using System.Reflection; + +namespace GameCult.Thing.Contract.Tests; + +/// +/// 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. +/// +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(); + 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(() => held.Add(Doors.Make(element)), $"{door.Name}: the held list grew"); + Assert.Throws(() => 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(() => 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(() => door.Call(args), $"{door.Name}: nil list")!; + Assert.That(list.Bound, Is.EqualTo("present"), door.Name); + } + } +}