Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dadfc6349 | ||
|
|
e25311a0c0 | ||
|
|
686b32831b | ||
|
|
9d8f5706ad | ||
|
|
f3e3198df9 | ||
|
|
19f8d9aeff | ||
|
|
a9f8b2c242 | ||
|
|
4f87e5c5bf | ||
|
|
9405c7a595 | ||
|
|
963900cc0d | ||
|
|
1495952e53 | ||
|
|
9586392d14 | ||
|
|
fe376ce9bd | ||
|
|
b8e8abd981 | ||
|
|
a6a7a97634 | ||
|
|
ee80246cd0 | ||
|
|
85baafcb1d | ||
|
|
9ed4a72a53 | ||
|
|
3e46195b3f | ||
|
|
280979c86d | ||
|
|
33eed442c7 |
34 changed files with 6773 additions and 22 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -14,6 +14,8 @@ packages/org.gamecult.eve.surface/bin~/
|
|||
packages/org.gamecult.eve.surface/obj~/
|
||||
!packages/org.gamecult.eve.unity-uitoolkit/
|
||||
!packages/org.gamecult.eve.unity-uitoolkit/**
|
||||
!packages/GameCult.Thing.Contract/
|
||||
!packages/GameCult.Thing.Contract/**
|
||||
obj/
|
||||
artifacts/
|
||||
tools/deps/
|
||||
|
|
|
|||
|
|
@ -159,8 +159,10 @@ pub struct TextInputNode {
|
|||
pub common: NodeCommon,
|
||||
#[cultcache(key = 1)]
|
||||
pub multiline: bool,
|
||||
/// Named `input_kind` in every runtime (Rust `input_kind`, C# `InputKind`, TypeScript `inputKind`): a field
|
||||
/// named `kind` collides with the union tag in TypeScript. The wire is the slot key, 2.
|
||||
#[cultcache(key = 2)]
|
||||
pub kind: InputKind,
|
||||
pub input_kind: InputKind,
|
||||
#[cultcache(key = 3)]
|
||||
pub placeholder: TextSource,
|
||||
#[cultcache(key = 4)]
|
||||
|
|
|
|||
|
|
@ -3,8 +3,29 @@
|
|||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use thing_contract::*;
|
||||
|
||||
pub fn goldens_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens")
|
||||
}
|
||||
|
||||
pub fn blessing() -> bool {
|
||||
std::env::var("THING_BLESS").as_deref() == Ok("1")
|
||||
}
|
||||
|
||||
/// Bless the file or compare against it. Returns the committed bytes.
|
||||
pub fn golden_bytes(file: &str, produced: &[u8]) -> Vec<u8> {
|
||||
let path = goldens_dir().join(file);
|
||||
if blessing() {
|
||||
std::fs::write(&path, produced).unwrap_or_else(|error| panic!("{file}: {error}"));
|
||||
}
|
||||
let committed = std::fs::read(&path).unwrap_or_else(|error| panic!("{file}: {error}"));
|
||||
assert_eq!(committed, produced, "{file}: the committed golden differs from what the crate writes");
|
||||
committed
|
||||
}
|
||||
|
||||
pub fn seq(value: i32) -> Seq {
|
||||
Seq::new(value).unwrap()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,25 +16,6 @@ const PINNED_CLOCK: &str = "2026-01-01T00:00:00Z";
|
|||
// A value that must never appear in a refusal.
|
||||
const CANARY: &str = "CANARY-7f3a91c2";
|
||||
|
||||
fn goldens_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens")
|
||||
}
|
||||
|
||||
fn blessing() -> bool {
|
||||
std::env::var("THING_BLESS").as_deref() == Ok("1")
|
||||
}
|
||||
|
||||
/// Bless the file or compare against it. Returns the committed bytes.
|
||||
fn golden_bytes(file: &str, produced: &[u8]) -> Vec<u8> {
|
||||
let path = goldens_dir().join(file);
|
||||
if blessing() {
|
||||
std::fs::write(&path, produced).unwrap_or_else(|error| panic!("{file}: {error}"));
|
||||
}
|
||||
let committed = std::fs::read(&path).unwrap_or_else(|error| panic!("{file}: {error}"));
|
||||
assert_eq!(committed, produced, "{file}: the committed golden differs from what the crate writes");
|
||||
committed
|
||||
}
|
||||
|
||||
fn golden<T: DatabaseEntry + PartialEq + Debug>(name: &str, value: &T) -> String {
|
||||
let produced = rmp_serde::to_vec(value).unwrap();
|
||||
let committed = golden_bytes(&format!("{name}.bin"), &produced);
|
||||
|
|
@ -150,7 +131,7 @@ fn full_surface() -> SurfaceDocument {
|
|||
Node::TextInput(TextInputNode {
|
||||
common: common("name-input"),
|
||||
multiline: true,
|
||||
kind: InputKind::Number,
|
||||
input_kind: InputKind::Number,
|
||||
placeholder: lit_text("name"),
|
||||
initial: TextSource::Param(param("initial")),
|
||||
submit: command("rename"),
|
||||
|
|
@ -158,7 +139,7 @@ fn full_surface() -> SurfaceDocument {
|
|||
Node::TextInput(TextInputNode {
|
||||
common: common("title-input"),
|
||||
multiline: false,
|
||||
kind: InputKind::Text,
|
||||
input_kind: InputKind::Text,
|
||||
placeholder: lit_text("title"),
|
||||
initial: TextSource::Param(param("initial")),
|
||||
submit: command("retitle"),
|
||||
|
|
|
|||
|
|
@ -40,3 +40,9 @@ when absent. Arm keys are fixed for v1.
|
|||
| `rgba-channel-out-of-range.bin` | the Rgba slot array `[256, 0, 0, 255]` | refused when decoded as an Rgba: a channel is a byte, 0 to 255 |
|
||||
|
||||
Domain refusals are the contract's rule, which the TypeScript and C# packages mirror: each is held to refuse these files, without the leaf set a Rust `u8` or a checked newtype has.
|
||||
|
||||
## The nil vector
|
||||
|
||||
`nil-positions.tsv` is written only by `crates/thing-contract/tests/nil_positions.rs` (`THING_BLESS=1`). For every element inside an array, at every depth, of each value golden above except `surface.cc` (the eleven `.bin` files from `surface-all-arms` to `asset-catalog`), the test replaces that element with nil (`0xC0`) and decodes the whole payload with the Rust decode door, `rmp_serde::from_slice` into the golden's kind. A row is `golden`, `path` (dotted array indices from the root, e.g. `3.0.1`), the element's byte `start` and `end` in the golden, and `ACCEPT` or `REFUSE`. An accepted variant re-encodes to the spliced bytes. Maps are not walked.
|
||||
|
||||
A slot is required exactly when its Rust type is not optional, so `REFUSE` rows are the required slots and `ACCEPT` rows the optional ones. The Rust decode door decides every row; the TypeScript and C# packages keep no list of required slots and are held to every row.
|
||||
|
|
|
|||
2866
crates/thing-contract/tests/goldens/nil-positions.tsv
Normal file
2866
crates/thing-contract/tests/goldens/nil-positions.tsv
Normal file
File diff suppressed because it is too large
Load diff
129
crates/thing-contract/tests/nil_positions.rs
Normal file
129
crates/thing-contract/tests/nil_positions.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
//! The nil vector: for every element of every array of every value golden, the golden with that
|
||||
//! element replaced by nil, and whether the Rust decode door accepts it. A slot is required
|
||||
//! exactly when its Rust type is not optional, so the rows are the required-slot set; the C# and
|
||||
//! TypeScript packages hold no list of their own and are held to every row.
|
||||
//! `THING_BLESS=1` rewrites `goldens/nil-positions.tsv`; any other run compares.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::fmt::Debug;
|
||||
|
||||
use common::*;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use thing_contract::*;
|
||||
|
||||
/// One MessagePack value's byte span; `kids` is set for arrays only (maps are never walked).
|
||||
struct Span {
|
||||
start: usize,
|
||||
end: usize,
|
||||
kids: Option<Vec<Span>>,
|
||||
}
|
||||
|
||||
fn be(bytes: &[u8], at: usize, width: usize) -> usize {
|
||||
bytes[at..at + width].iter().fold(0, |acc, byte| acc << 8 | *byte as usize)
|
||||
}
|
||||
|
||||
fn collection(bytes: &[u8], start: usize, mut at: usize, count: usize, is_array: bool) -> Span {
|
||||
let mut kids = Vec::new();
|
||||
for _ in 0..count {
|
||||
let kid = item(bytes, at);
|
||||
at = kid.end;
|
||||
kids.push(kid);
|
||||
}
|
||||
Span { start, end: at, kids: is_array.then_some(kids) }
|
||||
}
|
||||
|
||||
fn item(bytes: &[u8], at: usize) -> Span {
|
||||
let tag = bytes[at];
|
||||
let flat = |len: usize| Span { start: at, end: at + len, kids: None };
|
||||
match tag {
|
||||
0x00..=0x7f | 0xe0..=0xff | 0xc0 | 0xc2 | 0xc3 => flat(1),
|
||||
0x80..=0x8f => collection(bytes, at, at + 1, (tag & 0x0f) as usize * 2, false),
|
||||
0x90..=0x9f => collection(bytes, at, at + 1, (tag & 0x0f) as usize, true),
|
||||
0xa0..=0xbf => flat(1 + (tag & 0x1f) as usize),
|
||||
0xc4 | 0xd9 => flat(2 + be(bytes, at + 1, 1)),
|
||||
0xc5 | 0xda => flat(3 + be(bytes, at + 1, 2)),
|
||||
0xc6 | 0xdb => flat(5 + be(bytes, at + 1, 4)),
|
||||
0xca => flat(5),
|
||||
0xcb => flat(9),
|
||||
0xcc | 0xd0 => flat(2),
|
||||
0xcd | 0xd1 => flat(3),
|
||||
0xce | 0xd2 => flat(5),
|
||||
0xcf | 0xd3 => flat(9),
|
||||
0xdc => collection(bytes, at, at + 3, be(bytes, at + 1, 2), true),
|
||||
0xdd => collection(bytes, at, at + 5, be(bytes, at + 1, 4), true),
|
||||
0xde => collection(bytes, at, at + 3, be(bytes, at + 1, 2) * 2, false),
|
||||
0xdf => collection(bytes, at, at + 5, be(bytes, at + 1, 4) * 2, false),
|
||||
0xd4 => flat(3),
|
||||
0xd5 => flat(4),
|
||||
0xd6 => flat(6),
|
||||
0xd7 => flat(10),
|
||||
0xd8 => flat(18),
|
||||
0xc7 => flat(3 + be(bytes, at + 1, 1)),
|
||||
0xc8 => flat(4 + be(bytes, at + 1, 2)),
|
||||
0xc9 => flat(6 + be(bytes, at + 1, 4)),
|
||||
_ => panic!("unsupported tag {tag:#x} at {at}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every element inside an array, at every depth, in walk order, with its dotted index path.
|
||||
fn elements(span: &Span, prefix: &str, out: &mut Vec<(String, usize, usize)>) {
|
||||
for (index, kid) in span.kids.iter().flatten().enumerate() {
|
||||
let path = if prefix.is_empty() { index.to_string() } else { format!("{prefix}.{index}") };
|
||||
out.push((path.clone(), kid.start, kid.end));
|
||||
elements(kid, &path, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows of one golden: nil spliced over each element, decoded as `T`. An accepted variant
|
||||
/// must re-encode to the spliced bytes.
|
||||
fn rows<T: DeserializeOwned + Serialize + Debug>(file: &str, out: &mut String) -> (usize, usize) {
|
||||
let bytes = std::fs::read(goldens_dir().join(format!("{file}.bin"))).unwrap();
|
||||
let mut found = Vec::new();
|
||||
elements(&item(&bytes, 0), "", &mut found);
|
||||
let (mut accepted, mut refused) = (0, 0);
|
||||
for (path, start, end) in found {
|
||||
let mut spliced = bytes[..start].to_vec();
|
||||
spliced.push(0xc0);
|
||||
spliced.extend_from_slice(&bytes[end..]);
|
||||
let verdict = match rmp_serde::from_slice::<T>(&spliced) {
|
||||
Ok(value) => {
|
||||
assert_eq!(rmp_serde::to_vec(&value).unwrap(), spliced, "{file} {path}: an accepted nil must re-encode to the spliced bytes");
|
||||
accepted += 1;
|
||||
"ACCEPT"
|
||||
}
|
||||
Err(_) => {
|
||||
refused += 1;
|
||||
"REFUSE"
|
||||
}
|
||||
};
|
||||
out.push_str(&format!("{file}\t{path}\t{start}\t{end}\t{verdict}\n"));
|
||||
}
|
||||
(accepted, refused)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nil_positions_match() {
|
||||
let mut table = String::new();
|
||||
let mut totals = Vec::new();
|
||||
totals.push(("surface-all-arms", rows::<SurfaceDocument>("surface-all-arms", &mut table)));
|
||||
totals.push(("composition-all-params", rows::<CompositionDocument>("composition-all-params", &mut table)));
|
||||
totals.push(("invocation-all-inputs", rows::<CommandInvocation>("invocation-all-inputs", &mut table)));
|
||||
for file in [
|
||||
"result-pending",
|
||||
"result-completed",
|
||||
"result-completed-open-surface",
|
||||
"result-completed-open-external",
|
||||
"result-denied",
|
||||
"result-denied-no-node",
|
||||
"result-failed",
|
||||
] {
|
||||
totals.push((file, rows::<CommandResult>(file, &mut table)));
|
||||
}
|
||||
totals.push(("asset-catalog", rows::<AssetCatalog>("asset-catalog", &mut table)));
|
||||
for (file, (accepted, refused)) in &totals {
|
||||
println!("NILTOTAL {file} accept={accepted} refuse={refused}");
|
||||
}
|
||||
golden_bytes("nil-positions.tsv", table.as_bytes());
|
||||
}
|
||||
44
packages/GameCult.Thing.Contract/Asset.cs
Normal file
44
packages/GameCult.Thing.Contract/Asset.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using System.Collections.Generic;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Thing.Contract;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AssetEntry
|
||||
{
|
||||
[Key(0)] public string Id { get; }
|
||||
[Key(1)] public string MediaType { get; }
|
||||
[Key(2)] public string Uri { get; }
|
||||
[Key(3)] public int Width { get; }
|
||||
[Key(4)] public int Height { get; }
|
||||
|
||||
public AssetEntry(string id, string mediaType, string uri, int width, int height)
|
||||
{
|
||||
Id = ThingDomain.Present(id, "Id");
|
||||
MediaType = ThingDomain.Present(mediaType, "MediaType");
|
||||
Uri = ThingDomain.Present(uri, "Uri");
|
||||
Width = ThingDomain.Count(width);
|
||||
Height = ThingDomain.Count(height);
|
||||
}
|
||||
}
|
||||
|
||||
[CultDocument("gamecult.thing.asset_catalog", "gamecult.thing.asset_catalog.v1")]
|
||||
[MessagePackObject]
|
||||
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 IReadOnlyList<AssetEntry> Assets { get; }
|
||||
|
||||
public AssetCatalog(string providerId, string catalogId, long version, IReadOnlyList<AssetEntry> assets)
|
||||
{
|
||||
ProviderId = ThingDomain.Present(providerId, "ProviderId");
|
||||
CatalogId = ThingDomain.Segment(catalogId);
|
||||
Version = ThingDomain.Version(version);
|
||||
Assets = ThingDomain.Items(assets, "Assets");
|
||||
}
|
||||
|
||||
[IgnoreMember] public string RecordKey => ThingKeys.AssetCatalogKey(ProviderId, CatalogId);
|
||||
}
|
||||
177
packages/GameCult.Thing.Contract/Command.cs
Normal file
177
packages/GameCult.Thing.Contract/Command.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Thing.Contract;
|
||||
|
||||
[Union(0, typeof(CommandIdRef))]
|
||||
[Union(1, typeof(CommandRefParam))]
|
||||
public abstract class CommandRef
|
||||
{
|
||||
internal CommandRef() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class CommandIdRef : CommandRef
|
||||
{
|
||||
[Key(0)] public string Id { get; }
|
||||
|
||||
public CommandIdRef(string id)
|
||||
{
|
||||
Id = ThingDomain.Present(id, "Id");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class CommandRefParam : CommandRef
|
||||
{
|
||||
[Key(0)] public string Name { get; }
|
||||
|
||||
public CommandRefParam(string name)
|
||||
{
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
}
|
||||
}
|
||||
|
||||
[Union(0, typeof(TextValue))]
|
||||
[Union(1, typeof(NumberValue))]
|
||||
[Union(2, typeof(BoolValue))]
|
||||
[Union(3, typeof(ChoiceValue))]
|
||||
public abstract class InputValue
|
||||
{
|
||||
internal InputValue() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class TextValue : InputValue
|
||||
{
|
||||
[Key(0)] public string Value { get; }
|
||||
|
||||
public TextValue(string value)
|
||||
{
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class NumberValue : InputValue
|
||||
{
|
||||
[Key(0)] public double Value { get; }
|
||||
|
||||
public NumberValue(double value)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class BoolValue : InputValue
|
||||
{
|
||||
[Key(0)] public bool Value { get; }
|
||||
|
||||
public BoolValue(bool value)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ChoiceValue : InputValue
|
||||
{
|
||||
[Key(0)] public string Value { get; }
|
||||
|
||||
public ChoiceValue(string value)
|
||||
{
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class CommandDecl
|
||||
{
|
||||
[Key(0)] public string Id { get; }
|
||||
[Key(1)] public string PayloadSchema { get; }
|
||||
[Key(2)] public IReadOnlyList<string> Captures { get; }
|
||||
[Key(3)] public string? Confirm { get; }
|
||||
|
||||
public CommandDecl(string id, string payloadSchema, IReadOnlyList<string> captures, string? confirm)
|
||||
{
|
||||
Id = ThingDomain.Present(id, "Id");
|
||||
PayloadSchema = ThingDomain.Present(payloadSchema, "PayloadSchema");
|
||||
Captures = ThingDomain.Items(captures, "Captures");
|
||||
Confirm = confirm;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A typed record as its MessagePack bytes. Every empty memory (<c>ReadOnlyMemory<byte>.Empty</c>, a default view,
|
||||
/// an empty array) is an empty payload, as Rust's empty <c>Vec<u8></c> is. A nil on the wire is not an empty
|
||||
/// payload: it decodes to a default view the constructor cannot tell from an empty one, so the constructor notes it and
|
||||
/// the decode door refuses it once the value is read.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class RecordPayload : IMessagePackSerializationCallbackReceiver
|
||||
{
|
||||
private readonly bool _viewWasDefault;
|
||||
|
||||
[Key(0)] public string Schema { get; }
|
||||
[Key(1)] public ReadOnlyMemory<byte> Bytes { get; }
|
||||
|
||||
public RecordPayload(string schema, ReadOnlyMemory<byte> bytes)
|
||||
{
|
||||
Schema = ThingDomain.Present(schema, "Schema");
|
||||
_viewWasDefault = bytes.Equals(default(ReadOnlyMemory<byte>));
|
||||
Bytes = bytes.ToArray();
|
||||
}
|
||||
|
||||
void IMessagePackSerializationCallbackReceiver.OnBeforeSerialize() { }
|
||||
|
||||
void IMessagePackSerializationCallbackReceiver.OnAfterDeserialize()
|
||||
{
|
||||
if (_viewWasDefault) throw new ThingDomainException("Bytes", "present");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class CapturedInput
|
||||
{
|
||||
[Key(0)] public string Node { get; }
|
||||
[Key(1)] public InputValue Value { get; }
|
||||
|
||||
public CapturedInput(string node, InputValue value)
|
||||
{
|
||||
Node = ThingDomain.Present(node, "Node");
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
}
|
||||
}
|
||||
|
||||
[CultDocument("gamecult.thing.command_invocation", "gamecult.thing.command_invocation.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class CommandInvocation
|
||||
{
|
||||
[Key(0)] public string InvocationId { get; }
|
||||
[Key(1)] public string SurfaceKey { get; }
|
||||
[Key(2)] public long SurfaceVersion { get; }
|
||||
[Key(3)] public string Command { get; }
|
||||
[Key(4)] public RecordPayload Payload { 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, IReadOnlyList<CapturedInput> captured, string client, string issuedAt, string? delegation)
|
||||
{
|
||||
InvocationId = ThingDomain.Present(invocationId, "InvocationId");
|
||||
SurfaceKey = ThingDomain.Present(surfaceKey, "SurfaceKey");
|
||||
SurfaceVersion = ThingDomain.Version(surfaceVersion);
|
||||
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;
|
||||
}
|
||||
|
||||
[IgnoreMember] public string RecordKey => ThingKeys.InvocationKey(InvocationId);
|
||||
}
|
||||
64
packages/GameCult.Thing.Contract/Domain.cs
Normal file
64
packages/GameCult.Thing.Contract/Domain.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace GameCult.Thing.Contract;
|
||||
|
||||
/// <summary>A value was outside its domain. Names the domain and its bound, never the value.</summary>
|
||||
public sealed class ThingDomainException : Exception
|
||||
{
|
||||
public ThingDomainException(string domain, string bound) : base($"{domain} must be {bound}")
|
||||
{
|
||||
Domain = domain;
|
||||
Bound = bound;
|
||||
}
|
||||
|
||||
/// <summary>The domain that refused.</summary>
|
||||
public string Domain { get; }
|
||||
|
||||
/// <summary>The bound that was not met.</summary>
|
||||
public string Bound { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
/// <summary>A result's position in its invocation's answers, counting from 1.</summary>
|
||||
public static int Seq(int value) => value >= 1 ? value : throw new ThingDomainException("Seq", "at least 1");
|
||||
|
||||
/// <summary>A document or source version, never negative.</summary>
|
||||
public static long Version(long value) => value >= 0 ? value : throw new ThingDomainException("Version", "at least 0");
|
||||
|
||||
public static long? Version(long? value) => value.HasValue ? Version(value.Value) : null;
|
||||
|
||||
/// <summary>A count, an extent or an index, never negative.</summary>
|
||||
public static int Count(int value) => value >= 0 ? value : throw new ThingDomainException("Count", "at least 0");
|
||||
|
||||
public static int? Count(int? value) => value.HasValue ? Count(value.Value) : null;
|
||||
|
||||
/// <summary>A name the contract joins into a key: non-empty, no '.'.</summary>
|
||||
public static string Segment(string value) =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Description>The gamecult.thing.* wire types, held to the Rust crate's goldens.</Description>
|
||||
<PackageId>GameCult.Thing.Contract</PackageId>
|
||||
<Version>0.1.0</Version>
|
||||
<Authors>GameCult</Authors>
|
||||
<PackageLicenseExpression>MPL-2.0</PackageLicenseExpression>
|
||||
<PackageProjectUrl>https://github.com/GameCult/Eve</PackageProjectUrl>
|
||||
<RepositoryUrl>https://github.com/GameCult/Eve</RepositoryUrl>
|
||||
<BaseOutputPath>obj\bin\</BaseOutputPath>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\CultLib</CultLibRoot>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
25
packages/GameCult.Thing.Contract/Keys.cs
Normal file
25
packages/GameCult.Thing.Contract/Keys.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using System.Globalization;
|
||||
|
||||
namespace GameCult.Thing.Contract;
|
||||
|
||||
/// <summary>
|
||||
/// The key of each kind. Each key joins free-string provider or publisher ids (dotted, as the Verse names them)
|
||||
/// with typed trailing parts: a segment holds no dot and a seq or version is digits, so a key splits at its last dot
|
||||
/// (surface, asset catalog, result) or last two (composition), and two different field sets never build one key.
|
||||
/// </summary>
|
||||
public static class ThingKeys
|
||||
{
|
||||
public static string SurfaceKey(string providerId, string label) =>
|
||||
$"thing:surface:{ThingDomain.Present(providerId, "ProviderId")}.{ThingDomain.Segment(label)}";
|
||||
|
||||
public static string CompositionKey(string publisher, string name, long version) =>
|
||||
$"thing:composition:{ThingDomain.Present(publisher, "Publisher")}.{ThingDomain.Segment(name)}.v{ThingDomain.Version(version).ToString(CultureInfo.InvariantCulture)}";
|
||||
|
||||
public static string InvocationKey(string invocationId) => $"thing:invocation:{ThingDomain.Present(invocationId, "InvocationId")}";
|
||||
|
||||
public static string ResultKey(string invocationId, int seq) =>
|
||||
$"thing:result:{ThingDomain.Present(invocationId, "InvocationId")}.{ThingDomain.Seq(seq).ToString(CultureInfo.InvariantCulture)}";
|
||||
|
||||
public static string AssetCatalogKey(string providerId, string catalogId) =>
|
||||
$"thing:asset_catalog:{ThingDomain.Present(providerId, "ProviderId")}.{ThingDomain.Segment(catalogId)}";
|
||||
}
|
||||
663
packages/GameCult.Thing.Contract/Node.cs
Normal file
663
packages/GameCult.Thing.Contract/Node.cs
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
using System.Collections.Generic;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Thing.Contract;
|
||||
|
||||
[Union(0, typeof(BoxNode))]
|
||||
[Union(1, typeof(TextNode))]
|
||||
[Union(2, typeof(ImageNode))]
|
||||
[Union(3, typeof(ButtonNode))]
|
||||
[Union(4, typeof(ToggleNode))]
|
||||
[Union(5, typeof(SliderNode))]
|
||||
[Union(6, typeof(TextInputNode))]
|
||||
[Union(7, typeof(SelectNode))]
|
||||
[Union(8, typeof(RepeatNode))]
|
||||
[Union(9, typeof(OverlayNode))]
|
||||
[Union(10, typeof(PlotNode))]
|
||||
[Union(11, typeof(EmbedNode))]
|
||||
[Union(12, typeof(UseNode))]
|
||||
public abstract class Node
|
||||
{
|
||||
internal Node() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class BoxNode : Node
|
||||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public BoxStyle Style { get; }
|
||||
[Key(2)] public Paint Paint { get; }
|
||||
[Key(3)] public Role Role { get; }
|
||||
[Key(4)] public IReadOnlyList<Node> Children { get; }
|
||||
|
||||
public BoxNode(NodeCommon common, BoxStyle style, Paint paint, Role role, IReadOnlyList<Node> 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");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class TextNode : Node
|
||||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public TextSource Content { get; }
|
||||
[Key(2)] public string StyleToken { get; }
|
||||
[Key(3)] public bool Wrap { get; }
|
||||
[Key(4)] public int? MaxLines { get; }
|
||||
[Key(5)] public bool Selectable { get; }
|
||||
|
||||
public TextNode(NodeCommon common, TextSource content, string styleToken, bool wrap, int? maxLines, bool selectable)
|
||||
{
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Content = ThingDomain.Present(content, "Content");
|
||||
StyleToken = ThingDomain.Present(styleToken, "StyleToken");
|
||||
Wrap = wrap;
|
||||
MaxLines = ThingDomain.Count(maxLines);
|
||||
Selectable = selectable;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ImageNode : Node
|
||||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public string Catalog { get; }
|
||||
[Key(2)] public string Asset { get; }
|
||||
[Key(3)] public ImageFit Fit { get; }
|
||||
[Key(4)] public TextSource Alt { get; }
|
||||
|
||||
public ImageNode(NodeCommon common, string catalog, string asset, ImageFit fit, TextSource 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");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ButtonNode : Node
|
||||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public IReadOnlyList<Node> Content { get; }
|
||||
[Key(2)] public CommandRef OnActivate { get; }
|
||||
[Key(3)] public BoolSource Enabled { get; }
|
||||
|
||||
public ButtonNode(NodeCommon common, IReadOnlyList<Node> content, CommandRef onActivate, BoolSource enabled)
|
||||
{
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Content = ThingDomain.Items(content, "Content");
|
||||
OnActivate = ThingDomain.Present(onActivate, "OnActivate");
|
||||
Enabled = ThingDomain.Present(enabled, "Enabled");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ToggleNode : Node
|
||||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public BoolSource Checked { get; }
|
||||
[Key(2)] public CommandRef OnChange { get; }
|
||||
|
||||
public ToggleNode(NodeCommon common, BoolSource @checked, CommandRef onChange)
|
||||
{
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Checked = ThingDomain.Present(@checked, "Checked");
|
||||
OnChange = ThingDomain.Present(onChange, "OnChange");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class SliderNode : Node
|
||||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public double Min { get; }
|
||||
[Key(2)] public double Max { get; }
|
||||
[Key(3)] public double Step { get; }
|
||||
[Key(4)] public NumberSource Value { get; }
|
||||
[Key(5)] public CommandRef Commit { get; }
|
||||
|
||||
public SliderNode(NodeCommon common, double min, double max, double step, NumberSource value, CommandRef commit)
|
||||
{
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Min = min;
|
||||
Max = max;
|
||||
Step = step;
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
Commit = ThingDomain.Present(commit, "Commit");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class TextInputNode : Node
|
||||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public bool Multiline { 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 inputKind, TextSource placeholder, TextSource initial, CommandRef submit)
|
||||
{
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Multiline = multiline;
|
||||
InputKind = ThingDomain.Present(inputKind, "InputKind");
|
||||
Placeholder = ThingDomain.Present(placeholder, "Placeholder");
|
||||
Initial = ThingDomain.Present(initial, "Initial");
|
||||
Submit = ThingDomain.Present(submit, "Submit");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class SelectNode : Node
|
||||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public OptionList Options { get; }
|
||||
[Key(2)] public Bind Value { get; }
|
||||
[Key(3)] public CommandRef OnChange { get; }
|
||||
|
||||
public SelectNode(NodeCommon common, OptionList options, Bind value, CommandRef onChange)
|
||||
{
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Options = ThingDomain.Present(options, "Options");
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
OnChange = ThingDomain.Present(onChange, "OnChange");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RepeatNode : Node
|
||||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public CollectionSource Collection { get; }
|
||||
[Key(2)] public MemberPath KeyMember { get; }
|
||||
[Key(3)] public Node Template { get; }
|
||||
|
||||
public RepeatNode(NodeCommon common, CollectionSource collection, MemberPath keyMember, Node template)
|
||||
{
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Collection = ThingDomain.Present(collection, "Collection");
|
||||
KeyMember = ThingDomain.Present(keyMember, "KeyMember");
|
||||
Template = ThingDomain.Present(template, "Template");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class OverlayNode : Node
|
||||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public Anchor Anchor { get; }
|
||||
[Key(2)] public bool Modal { get; }
|
||||
[Key(3)] public CommandRef Dismiss { get; }
|
||||
[Key(4)] public IReadOnlyList<Node> Children { get; }
|
||||
|
||||
public OverlayNode(NodeCommon common, Anchor anchor, bool modal, CommandRef dismiss, IReadOnlyList<Node> children)
|
||||
{
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Anchor = ThingDomain.Present(anchor, "Anchor");
|
||||
Modal = modal;
|
||||
Dismiss = ThingDomain.Present(dismiss, "Dismiss");
|
||||
Children = ThingDomain.Items(children, "Children");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class PlotNode : Node
|
||||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public Bind Series { get; }
|
||||
[Key(2)] public PlotMark Mark { get; }
|
||||
[Key(3)] public double? XMin { get; }
|
||||
[Key(4)] public double? XMax { get; }
|
||||
[Key(5)] public double? YMin { get; }
|
||||
[Key(6)] public double? YMax { get; }
|
||||
|
||||
public PlotNode(NodeCommon common, Bind series, PlotMark mark, double? xMin, double? xMax, double? yMin, double? yMax)
|
||||
{
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Series = ThingDomain.Present(series, "Series");
|
||||
Mark = ThingDomain.Present(mark, "Mark");
|
||||
XMin = xMin;
|
||||
XMax = xMax;
|
||||
YMin = yMin;
|
||||
YMax = yMax;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class EmbedNode : Node
|
||||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public string SlotKind { get; }
|
||||
[Key(2)] public string SlotId { get; }
|
||||
[Key(3)] public double Width { get; }
|
||||
[Key(4)] public double Height { get; }
|
||||
|
||||
public EmbedNode(NodeCommon common, string slotKind, string slotId, double width, double height)
|
||||
{
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
SlotKind = ThingDomain.Present(slotKind, "SlotKind");
|
||||
SlotId = ThingDomain.Present(slotId, "SlotId");
|
||||
Width = width;
|
||||
Height = height;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class UseNode : Node
|
||||
{
|
||||
[Key(0)] public NodeCommon Common { get; }
|
||||
[Key(1)] public CompositionRef Composition { get; }
|
||||
[Key(2)] public IReadOnlyList<Arg> Args { get; }
|
||||
|
||||
public UseNode(NodeCommon common, CompositionRef composition, IReadOnlyList<Arg> args)
|
||||
{
|
||||
Common = ThingDomain.Present(common, "Common");
|
||||
Composition = ThingDomain.Present(composition, "Composition");
|
||||
Args = ThingDomain.Items(args, "Args");
|
||||
}
|
||||
}
|
||||
|
||||
[Union(0, typeof(ImageFitContain))]
|
||||
[Union(1, typeof(ImageFitCover))]
|
||||
[Union(2, typeof(ImageFitFill))]
|
||||
[Union(3, typeof(ImageFitUnscaled))]
|
||||
public abstract class ImageFit
|
||||
{
|
||||
internal ImageFit() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ImageFitContain : ImageFit
|
||||
{
|
||||
public ImageFitContain() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ImageFitCover : ImageFit
|
||||
{
|
||||
public ImageFitCover() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ImageFitFill : ImageFit
|
||||
{
|
||||
public ImageFitFill() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ImageFitUnscaled : ImageFit
|
||||
{
|
||||
public ImageFitUnscaled() { }
|
||||
}
|
||||
|
||||
[Union(0, typeof(InputKindText))]
|
||||
[Union(1, typeof(InputKindNumber))]
|
||||
public abstract class InputKind
|
||||
{
|
||||
internal InputKind() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class InputKindText : InputKind
|
||||
{
|
||||
public InputKindText() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class InputKindNumber : InputKind
|
||||
{
|
||||
public InputKindNumber() { }
|
||||
}
|
||||
|
||||
[Union(0, typeof(LiteralOptions))]
|
||||
[Union(1, typeof(BoundOptions))]
|
||||
public abstract class OptionList
|
||||
{
|
||||
internal OptionList() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class LiteralOptions : OptionList
|
||||
{
|
||||
[Key(0)] public IReadOnlyList<SelectOption> Options { get; }
|
||||
|
||||
public LiteralOptions(IReadOnlyList<SelectOption> options)
|
||||
{
|
||||
Options = ThingDomain.Items(options, "Options");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class BoundOptions : OptionList
|
||||
{
|
||||
[Key(0)] public CollectionSource Collection { get; }
|
||||
[Key(1)] public MemberPath ValueMember { get; }
|
||||
[Key(2)] public MemberPath LabelMember { get; }
|
||||
|
||||
public BoundOptions(CollectionSource collection, MemberPath valueMember, MemberPath labelMember)
|
||||
{
|
||||
Collection = ThingDomain.Present(collection, "Collection");
|
||||
ValueMember = ThingDomain.Present(valueMember, "ValueMember");
|
||||
LabelMember = ThingDomain.Present(labelMember, "LabelMember");
|
||||
}
|
||||
}
|
||||
|
||||
[Union(0, typeof(AnchorViewport))]
|
||||
[Union(1, typeof(AnchorNode))]
|
||||
public abstract class Anchor
|
||||
{
|
||||
internal Anchor() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AnchorViewport : Anchor
|
||||
{
|
||||
public AnchorViewport() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AnchorNode : Anchor
|
||||
{
|
||||
[Key(0)] public string Node { get; }
|
||||
|
||||
public AnchorNode(string node)
|
||||
{
|
||||
Node = ThingDomain.Present(node, "Node");
|
||||
}
|
||||
}
|
||||
|
||||
[Union(0, typeof(PlotMarkLine))]
|
||||
[Union(1, typeof(PlotMarkBar))]
|
||||
[Union(2, typeof(PlotMarkArea))]
|
||||
public abstract class PlotMark
|
||||
{
|
||||
internal PlotMark() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class PlotMarkLine : PlotMark
|
||||
{
|
||||
public PlotMarkLine() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class PlotMarkBar : PlotMark
|
||||
{
|
||||
public PlotMarkBar() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class PlotMarkArea : PlotMark
|
||||
{
|
||||
public PlotMarkArea() { }
|
||||
}
|
||||
|
||||
[Union(0, typeof(TextArg))]
|
||||
[Union(1, typeof(NumberArg))]
|
||||
[Union(2, typeof(BoolArg))]
|
||||
[Union(3, typeof(ArgValueBind))]
|
||||
[Union(4, typeof(CollectionArg))]
|
||||
[Union(5, typeof(CommandArg))]
|
||||
[Union(6, typeof(SlotArg))]
|
||||
public abstract class ArgValue
|
||||
{
|
||||
internal ArgValue() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class TextArg : ArgValue
|
||||
{
|
||||
[Key(0)] public string Value { get; }
|
||||
|
||||
public TextArg(string value)
|
||||
{
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class NumberArg : ArgValue
|
||||
{
|
||||
[Key(0)] public double Value { get; }
|
||||
|
||||
public NumberArg(double value)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class BoolArg : ArgValue
|
||||
{
|
||||
[Key(0)] public bool Value { get; }
|
||||
|
||||
public BoolArg(bool value)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ArgValueBind : ArgValue
|
||||
{
|
||||
[Key(0)] public StateSource Source { get; }
|
||||
[Key(1)] public MemberPath Member { get; }
|
||||
[Key(2)] public Format? Format { get; }
|
||||
|
||||
public ArgValueBind(StateSource source, MemberPath member, Format? format)
|
||||
{
|
||||
Source = ThingDomain.Present(source, "Source");
|
||||
Member = ThingDomain.Present(member, "Member");
|
||||
Format = format;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class CollectionArg : ArgValue
|
||||
{
|
||||
[Key(0)] public CollectionSource Source { get; }
|
||||
|
||||
public CollectionArg(CollectionSource source)
|
||||
{
|
||||
Source = ThingDomain.Present(source, "Source");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class CommandArg : ArgValue
|
||||
{
|
||||
[Key(0)] public CommandRef Command { get; }
|
||||
|
||||
public CommandArg(CommandRef command)
|
||||
{
|
||||
Command = ThingDomain.Present(command, "Command");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class SlotArg : ArgValue
|
||||
{
|
||||
[Key(0)] public IReadOnlyList<Node> Nodes { get; }
|
||||
|
||||
public SlotArg(IReadOnlyList<Node> nodes)
|
||||
{
|
||||
Nodes = ThingDomain.Items(nodes, "Nodes");
|
||||
}
|
||||
}
|
||||
|
||||
[Union(0, typeof(ParamKindText))]
|
||||
[Union(1, typeof(ParamKindNumber))]
|
||||
[Union(2, typeof(ParamKindBool))]
|
||||
[Union(3, typeof(ParamKindBind))]
|
||||
[Union(4, typeof(ParamKindCollection))]
|
||||
[Union(5, typeof(ParamKindCommand))]
|
||||
[Union(6, typeof(ParamKindSlot))]
|
||||
public abstract class ParamKind
|
||||
{
|
||||
internal ParamKind() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ParamKindText : ParamKind
|
||||
{
|
||||
public ParamKindText() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ParamKindNumber : ParamKind
|
||||
{
|
||||
public ParamKindNumber() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ParamKindBool : ParamKind
|
||||
{
|
||||
public ParamKindBool() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ParamKindBind : ParamKind
|
||||
{
|
||||
public ParamKindBind() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ParamKindCollection : ParamKind
|
||||
{
|
||||
public ParamKindCollection() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ParamKindCommand : ParamKind
|
||||
{
|
||||
public ParamKindCommand() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ParamKindSlot : ParamKind
|
||||
{
|
||||
public ParamKindSlot() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class NodeCommon
|
||||
{
|
||||
[Key(0)] public string Id { get; }
|
||||
[Key(1)] public BoolSource Visible { get; }
|
||||
[Key(2)] public TextSource? A11yLabel { get; }
|
||||
|
||||
public NodeCommon(string id, BoolSource visible, TextSource? a11yLabel)
|
||||
{
|
||||
Id = ThingDomain.Present(id, "Id");
|
||||
Visible = ThingDomain.Present(visible, "Visible");
|
||||
A11yLabel = a11yLabel;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class SelectOption
|
||||
{
|
||||
[Key(0)] public string Value { get; }
|
||||
[Key(1)] public string Label { get; }
|
||||
|
||||
public SelectOption(string value, string label)
|
||||
{
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
Label = ThingDomain.Present(label, "Label");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class CompositionRef
|
||||
{
|
||||
[Key(0)] public string Publisher { get; }
|
||||
[Key(1)] public string Name { get; }
|
||||
[Key(2)] public long Version { get; }
|
||||
|
||||
public CompositionRef(string publisher, string name, long version)
|
||||
{
|
||||
Publisher = ThingDomain.Present(publisher, "Publisher");
|
||||
Name = ThingDomain.Segment(name);
|
||||
Version = ThingDomain.Version(version);
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class Arg
|
||||
{
|
||||
[Key(0)] public string Name { get; }
|
||||
[Key(1)] public ArgValue Value { get; }
|
||||
|
||||
public Arg(string name, ArgValue value)
|
||||
{
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ParamDecl
|
||||
{
|
||||
[Key(0)] public string Name { get; }
|
||||
[Key(1)] public ParamKind Kind { get; }
|
||||
|
||||
public ParamDecl(string name, ParamKind kind)
|
||||
{
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
Kind = ThingDomain.Present(kind, "Kind");
|
||||
}
|
||||
}
|
||||
|
||||
[CultDocument("gamecult.thing.surface", "gamecult.thing.surface.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class SurfaceDocument
|
||||
{
|
||||
[Key(0)] public string ProviderId { get; }
|
||||
[Key(1)] public string Label { get; }
|
||||
[Key(2)] public long Version { get; }
|
||||
[Key(3)] public string Title { get; }
|
||||
[Key(4)] public Node Root { get; }
|
||||
[Key(5)] public IReadOnlyList<CommandDecl> Commands { get; }
|
||||
|
||||
public SurfaceDocument(string providerId, string label, long version, string title, Node root, IReadOnlyList<CommandDecl> commands)
|
||||
{
|
||||
ProviderId = ThingDomain.Present(providerId, "ProviderId");
|
||||
Label = ThingDomain.Segment(label);
|
||||
Version = ThingDomain.Version(version);
|
||||
Title = ThingDomain.Present(title, "Title");
|
||||
Root = ThingDomain.Present(root, "Root");
|
||||
Commands = ThingDomain.Items(commands, "Commands");
|
||||
}
|
||||
|
||||
[IgnoreMember] public string RecordKey => ThingKeys.SurfaceKey(ProviderId, Label);
|
||||
}
|
||||
|
||||
[CultDocument("gamecult.thing.composition", "gamecult.thing.composition.v1")]
|
||||
[MessagePackObject]
|
||||
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 IReadOnlyList<ParamDecl> Params { get; }
|
||||
[Key(4)] public Node Body { get; }
|
||||
|
||||
public CompositionDocument(string publisher, string name, long version, IReadOnlyList<ParamDecl> @params, Node body)
|
||||
{
|
||||
Publisher = ThingDomain.Present(publisher, "Publisher");
|
||||
Name = ThingDomain.Segment(name);
|
||||
Version = ThingDomain.Version(version);
|
||||
Params = ThingDomain.Items(@params, "Params");
|
||||
Body = ThingDomain.Present(body, "Body");
|
||||
}
|
||||
|
||||
[IgnoreMember] public string RecordKey => ThingKeys.CompositionKey(Publisher, Name, Version);
|
||||
}
|
||||
132
packages/GameCult.Thing.Contract/Result.cs
Normal file
132
packages/GameCult.Thing.Contract/Result.cs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
using System.Collections.Generic;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Thing.Contract;
|
||||
|
||||
[Union(0, typeof(PendingOutcome))]
|
||||
[Union(1, typeof(CompletedOutcome))]
|
||||
[Union(2, typeof(DeniedOutcome))]
|
||||
[Union(3, typeof(FailedOutcome))]
|
||||
public abstract class Outcome
|
||||
{
|
||||
internal Outcome() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class PendingOutcome : Outcome
|
||||
{
|
||||
[Key(0)] public string Message { get; }
|
||||
|
||||
public PendingOutcome(string message)
|
||||
{
|
||||
Message = ThingDomain.Present(message, "Message");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class CompletedOutcome : Outcome
|
||||
{
|
||||
[Key(0)] public long? SourceVersion { get; }
|
||||
[Key(1)] public bool ClearDrafts { get; }
|
||||
[Key(2)] public Effect? Effect { get; }
|
||||
|
||||
public CompletedOutcome(long? sourceVersion, bool clearDrafts, Effect? effect)
|
||||
{
|
||||
SourceVersion = ThingDomain.Version(sourceVersion);
|
||||
ClearDrafts = clearDrafts;
|
||||
Effect = effect;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DeniedOutcome : Outcome
|
||||
{
|
||||
[Key(0)] public string Reason { get; }
|
||||
[Key(1)] public string? Node { get; }
|
||||
|
||||
public DeniedOutcome(string reason, string? node)
|
||||
{
|
||||
Reason = ThingDomain.Present(reason, "Reason");
|
||||
Node = node;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class FailedOutcome : Outcome
|
||||
{
|
||||
[Key(0)] public string Reason { get; }
|
||||
|
||||
public FailedOutcome(string reason)
|
||||
{
|
||||
Reason = ThingDomain.Present(reason, "Reason");
|
||||
}
|
||||
}
|
||||
|
||||
[Union(0, typeof(OpenSurfaceEffect))]
|
||||
[Union(1, typeof(OpenExternalEffect))]
|
||||
public abstract class Effect
|
||||
{
|
||||
internal Effect() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class OpenSurfaceEffect : Effect
|
||||
{
|
||||
[Key(0)] public string Key { get; }
|
||||
|
||||
public OpenSurfaceEffect(string key)
|
||||
{
|
||||
Key = ThingDomain.Present(key, "Key");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class OpenExternalEffect : Effect
|
||||
{
|
||||
[Key(0)] public string Url { get; }
|
||||
[Key(1)] public CommandRef Resume { get; }
|
||||
|
||||
public OpenExternalEffect(string url, CommandRef resume)
|
||||
{
|
||||
Url = ThingDomain.Present(url, "Url");
|
||||
Resume = ThingDomain.Present(resume, "Resume");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A provider's answer to one invocation. Results are built by the four factories and nothing else: the constructor is
|
||||
/// private (MessagePack calls it on decode, and it holds the same <see cref="ThingDomain.Seq"/> bound), and no API takes a
|
||||
/// state as free text or as an untyped value. <c>Seq</c> counts from 1 per invocation. The copied invocation id is a
|
||||
/// convenience: core binds a result to an invocation it issued, so the copy is not provenance.
|
||||
/// </summary>
|
||||
[CultDocument("gamecult.thing.command_result", "gamecult.thing.command_result.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
public sealed class CommandResult
|
||||
{
|
||||
[Key(0)] public string InvocationId { get; }
|
||||
[Key(1)] public int Seq { get; }
|
||||
[Key(2)] public Outcome Outcome { get; }
|
||||
|
||||
[SerializationConstructor]
|
||||
private CommandResult(string invocationId, int seq, Outcome outcome)
|
||||
{
|
||||
InvocationId = ThingDomain.Present(invocationId, "InvocationId");
|
||||
Seq = ThingDomain.Seq(seq);
|
||||
Outcome = ThingDomain.Present(outcome, "Outcome");
|
||||
}
|
||||
|
||||
[IgnoreMember] public string RecordKey => ThingKeys.ResultKey(InvocationId, Seq);
|
||||
|
||||
public static CommandResult Pending(CommandInvocation invocation, int seq, string 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(ThingDomain.Present(invocation, "Invocation").InvocationId, seq, new CompletedOutcome(sourceVersion, clearDrafts, effect));
|
||||
|
||||
public static CommandResult Denied(CommandInvocation invocation, int seq, string reason, string? node) =>
|
||||
new(ThingDomain.Present(invocation, "Invocation").InvocationId, seq, new DeniedOutcome(reason, node));
|
||||
|
||||
public static CommandResult Failed(CommandInvocation invocation, int seq, string reason) =>
|
||||
new(ThingDomain.Present(invocation, "Invocation").InvocationId, seq, new FailedOutcome(reason));
|
||||
}
|
||||
321
packages/GameCult.Thing.Contract/Source.cs
Normal file
321
packages/GameCult.Thing.Contract/Source.cs
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
using System.Collections.Generic;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Thing.Contract;
|
||||
|
||||
[Union(0, typeof(TextLiteral))]
|
||||
[Union(1, typeof(TextSourceBound))]
|
||||
[Union(2, typeof(TextSourceParam))]
|
||||
public abstract class TextSource
|
||||
{
|
||||
internal TextSource() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class TextLiteral : TextSource
|
||||
{
|
||||
[Key(0)] public string Value { get; }
|
||||
|
||||
public TextLiteral(string value)
|
||||
{
|
||||
Value = ThingDomain.Present(value, "Value");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class TextSourceBound : TextSource
|
||||
{
|
||||
[Key(0)] public StateSource Source { get; }
|
||||
[Key(1)] public MemberPath Member { get; }
|
||||
[Key(2)] public Format? Format { get; }
|
||||
|
||||
public TextSourceBound(StateSource source, MemberPath member, Format? format)
|
||||
{
|
||||
Source = ThingDomain.Present(source, "Source");
|
||||
Member = ThingDomain.Present(member, "Member");
|
||||
Format = format;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class TextSourceParam : TextSource
|
||||
{
|
||||
[Key(0)] public string Name { get; }
|
||||
|
||||
public TextSourceParam(string name)
|
||||
{
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
}
|
||||
}
|
||||
|
||||
[Union(0, typeof(NumberLiteral))]
|
||||
[Union(1, typeof(NumberSourceBound))]
|
||||
[Union(2, typeof(NumberSourceParam))]
|
||||
public abstract class NumberSource
|
||||
{
|
||||
internal NumberSource() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class NumberLiteral : NumberSource
|
||||
{
|
||||
[Key(0)] public double Value { get; }
|
||||
|
||||
public NumberLiteral(double value)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class NumberSourceBound : NumberSource
|
||||
{
|
||||
[Key(0)] public StateSource Source { get; }
|
||||
[Key(1)] public MemberPath Member { get; }
|
||||
[Key(2)] public Format? Format { get; }
|
||||
|
||||
public NumberSourceBound(StateSource source, MemberPath member, Format? format)
|
||||
{
|
||||
Source = ThingDomain.Present(source, "Source");
|
||||
Member = ThingDomain.Present(member, "Member");
|
||||
Format = format;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class NumberSourceParam : NumberSource
|
||||
{
|
||||
[Key(0)] public string Name { get; }
|
||||
|
||||
public NumberSourceParam(string name)
|
||||
{
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
}
|
||||
}
|
||||
|
||||
[Union(0, typeof(BoolLiteral))]
|
||||
[Union(1, typeof(BoolSourceBound))]
|
||||
[Union(2, typeof(BoolSourceParam))]
|
||||
public abstract class BoolSource
|
||||
{
|
||||
internal BoolSource() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class BoolLiteral : BoolSource
|
||||
{
|
||||
[Key(0)] public bool Value { get; }
|
||||
|
||||
public BoolLiteral(bool value)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class BoolSourceBound : BoolSource
|
||||
{
|
||||
[Key(0)] public StateSource Source { get; }
|
||||
[Key(1)] public MemberPath Member { get; }
|
||||
[Key(2)] public Format? Format { get; }
|
||||
|
||||
public BoolSourceBound(StateSource source, MemberPath member, Format? format)
|
||||
{
|
||||
Source = ThingDomain.Present(source, "Source");
|
||||
Member = ThingDomain.Present(member, "Member");
|
||||
Format = format;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class BoolSourceParam : BoolSource
|
||||
{
|
||||
[Key(0)] public string Name { get; }
|
||||
|
||||
public BoolSourceParam(string name)
|
||||
{
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
}
|
||||
}
|
||||
|
||||
[Union(0, typeof(StateRef))]
|
||||
[Union(1, typeof(StateSourceItem))]
|
||||
public abstract class StateSource
|
||||
{
|
||||
internal StateSource() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class StateRef : StateSource
|
||||
{
|
||||
[Key(0)] public string? Provider { get; }
|
||||
[Key(1)] public string Schema { get; }
|
||||
[Key(2)] public string Key { get; }
|
||||
|
||||
public StateRef(string? provider, string schema, string key)
|
||||
{
|
||||
Provider = provider;
|
||||
Schema = ThingDomain.Present(schema, "Schema");
|
||||
Key = ThingDomain.Present(key, "Key");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class StateSourceItem : StateSource
|
||||
{
|
||||
public StateSourceItem() { }
|
||||
}
|
||||
|
||||
[Union(0, typeof(MemberName))]
|
||||
[Union(1, typeof(MemberIndex))]
|
||||
public abstract class MemberStep
|
||||
{
|
||||
internal MemberStep() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class MemberName : MemberStep
|
||||
{
|
||||
[Key(0)] public string Name { get; }
|
||||
|
||||
public MemberName(string name)
|
||||
{
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class MemberIndex : MemberStep
|
||||
{
|
||||
[Key(0)] public int Index { get; }
|
||||
|
||||
public MemberIndex(int index)
|
||||
{
|
||||
Index = ThingDomain.Count(index);
|
||||
}
|
||||
}
|
||||
|
||||
[Union(0, typeof(NumberFormat))]
|
||||
[Union(1, typeof(FormatDuration))]
|
||||
[Union(2, typeof(FormatTimestamp))]
|
||||
[Union(3, typeof(AffixFormat))]
|
||||
public abstract class Format
|
||||
{
|
||||
internal Format() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class NumberFormat : Format
|
||||
{
|
||||
[Key(0)] public int Precision { get; }
|
||||
[Key(1)] public bool Percent { get; }
|
||||
[Key(2)] public string? Unit { get; }
|
||||
|
||||
public NumberFormat(int precision, bool percent, string? unit)
|
||||
{
|
||||
Precision = ThingDomain.Count(precision);
|
||||
Percent = percent;
|
||||
Unit = unit;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class FormatDuration : Format
|
||||
{
|
||||
public FormatDuration() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class FormatTimestamp : Format
|
||||
{
|
||||
public FormatTimestamp() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AffixFormat : Format
|
||||
{
|
||||
[Key(0)] public string Prefix { get; }
|
||||
[Key(1)] public string Suffix { get; }
|
||||
|
||||
public AffixFormat(string prefix, string suffix)
|
||||
{
|
||||
Prefix = ThingDomain.Present(prefix, "Prefix");
|
||||
Suffix = ThingDomain.Present(suffix, "Suffix");
|
||||
}
|
||||
}
|
||||
|
||||
[Union(0, typeof(RecordsSource))]
|
||||
[Union(1, typeof(CollectionSourceMember))]
|
||||
[Union(2, typeof(CollectionSourceParam))]
|
||||
public abstract class CollectionSource
|
||||
{
|
||||
internal CollectionSource() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RecordsSource : CollectionSource
|
||||
{
|
||||
[Key(0)] public string? Provider { get; }
|
||||
[Key(1)] public string Schema { get; }
|
||||
[Key(2)] public string KeyPrefix { get; }
|
||||
|
||||
public RecordsSource(string? provider, string schema, string keyPrefix)
|
||||
{
|
||||
Provider = provider;
|
||||
Schema = ThingDomain.Present(schema, "Schema");
|
||||
KeyPrefix = ThingDomain.Present(keyPrefix, "KeyPrefix");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class CollectionSourceMember : CollectionSource
|
||||
{
|
||||
[Key(0)] public StateSource Source { get; }
|
||||
[Key(1)] public MemberPath Member { get; }
|
||||
[Key(2)] public Format? Format { get; }
|
||||
|
||||
public CollectionSourceMember(StateSource source, MemberPath member, Format? format)
|
||||
{
|
||||
Source = ThingDomain.Present(source, "Source");
|
||||
Member = ThingDomain.Present(member, "Member");
|
||||
Format = format;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class CollectionSourceParam : CollectionSource
|
||||
{
|
||||
[Key(0)] public string Name { get; }
|
||||
|
||||
public CollectionSourceParam(string name)
|
||||
{
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class Bind
|
||||
{
|
||||
[Key(0)] public StateSource Source { get; }
|
||||
[Key(1)] public MemberPath Member { get; }
|
||||
[Key(2)] public Format? Format { get; }
|
||||
|
||||
public Bind(StateSource source, MemberPath member, Format? format)
|
||||
{
|
||||
Source = ThingDomain.Present(source, "Source");
|
||||
Member = ThingDomain.Present(member, "Member");
|
||||
Format = format;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class MemberPath
|
||||
{
|
||||
[Key(0)] public IReadOnlyList<MemberStep> Steps { get; }
|
||||
|
||||
public MemberPath(IReadOnlyList<MemberStep> steps)
|
||||
{
|
||||
Steps = ThingDomain.Items(steps, "Steps");
|
||||
}
|
||||
}
|
||||
496
packages/GameCult.Thing.Contract/Style.cs
Normal file
496
packages/GameCult.Thing.Contract/Style.cs
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
using System.Collections.Generic;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Thing.Contract;
|
||||
|
||||
[Union(0, typeof(DimensionPx))]
|
||||
[Union(1, typeof(DimensionPercent))]
|
||||
[Union(2, typeof(DimensionFr))]
|
||||
[Union(3, typeof(DimensionAuto))]
|
||||
[Union(4, typeof(DimensionContent))]
|
||||
public abstract class Dimension
|
||||
{
|
||||
internal Dimension() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DimensionPx : Dimension
|
||||
{
|
||||
[Key(0)] public double Value { get; }
|
||||
|
||||
public DimensionPx(double value)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DimensionPercent : Dimension
|
||||
{
|
||||
[Key(0)] public double Value { get; }
|
||||
|
||||
public DimensionPercent(double value)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DimensionFr : Dimension
|
||||
{
|
||||
[Key(0)] public double Value { get; }
|
||||
|
||||
public DimensionFr(double value)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DimensionAuto : Dimension
|
||||
{
|
||||
public DimensionAuto() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DimensionContent : Dimension
|
||||
{
|
||||
public DimensionContent() { }
|
||||
}
|
||||
|
||||
[Union(0, typeof(DisplayFlex))]
|
||||
[Union(1, typeof(DisplayGrid))]
|
||||
[Union(2, typeof(DisplayBlock))]
|
||||
public abstract class Display
|
||||
{
|
||||
internal Display() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DisplayFlex : Display
|
||||
{
|
||||
public DisplayFlex() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DisplayGrid : Display
|
||||
{
|
||||
public DisplayGrid() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DisplayBlock : Display
|
||||
{
|
||||
public DisplayBlock() { }
|
||||
}
|
||||
|
||||
[Union(0, typeof(DirectionRow))]
|
||||
[Union(1, typeof(DirectionColumn))]
|
||||
[Union(2, typeof(DirectionRowReverse))]
|
||||
[Union(3, typeof(DirectionColumnReverse))]
|
||||
public abstract class Direction
|
||||
{
|
||||
internal Direction() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DirectionRow : Direction
|
||||
{
|
||||
public DirectionRow() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DirectionColumn : Direction
|
||||
{
|
||||
public DirectionColumn() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DirectionRowReverse : Direction
|
||||
{
|
||||
public DirectionRowReverse() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DirectionColumnReverse : Direction
|
||||
{
|
||||
public DirectionColumnReverse() { }
|
||||
}
|
||||
|
||||
[Union(0, typeof(WrapNoWrap))]
|
||||
[Union(1, typeof(WrapWrap))]
|
||||
[Union(2, typeof(WrapWrapReverse))]
|
||||
public abstract class Wrap
|
||||
{
|
||||
internal Wrap() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WrapNoWrap : Wrap
|
||||
{
|
||||
public WrapNoWrap() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WrapWrap : Wrap
|
||||
{
|
||||
public WrapWrap() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WrapWrapReverse : Wrap
|
||||
{
|
||||
public WrapWrapReverse() { }
|
||||
}
|
||||
|
||||
[Union(0, typeof(PositionRelative))]
|
||||
[Union(1, typeof(PositionAbsolute))]
|
||||
public abstract class Position
|
||||
{
|
||||
internal Position() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class PositionRelative : Position
|
||||
{
|
||||
public PositionRelative() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class PositionAbsolute : Position
|
||||
{
|
||||
public PositionAbsolute() { }
|
||||
}
|
||||
|
||||
[Union(0, typeof(OverflowVisible))]
|
||||
[Union(1, typeof(OverflowClip))]
|
||||
[Union(2, typeof(OverflowScroll))]
|
||||
public abstract class Overflow
|
||||
{
|
||||
internal Overflow() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class OverflowVisible : Overflow
|
||||
{
|
||||
public OverflowVisible() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class OverflowClip : Overflow
|
||||
{
|
||||
public OverflowClip() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class OverflowScroll : Overflow
|
||||
{
|
||||
public OverflowScroll() { }
|
||||
}
|
||||
|
||||
[Union(0, typeof(ItemAlignAuto))]
|
||||
[Union(1, typeof(ItemAlignStart))]
|
||||
[Union(2, typeof(ItemAlignEnd))]
|
||||
[Union(3, typeof(ItemAlignCenter))]
|
||||
[Union(4, typeof(ItemAlignStretch))]
|
||||
[Union(5, typeof(ItemAlignBaseline))]
|
||||
public abstract class ItemAlign
|
||||
{
|
||||
internal ItemAlign() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ItemAlignAuto : ItemAlign
|
||||
{
|
||||
public ItemAlignAuto() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ItemAlignStart : ItemAlign
|
||||
{
|
||||
public ItemAlignStart() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ItemAlignEnd : ItemAlign
|
||||
{
|
||||
public ItemAlignEnd() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ItemAlignCenter : ItemAlign
|
||||
{
|
||||
public ItemAlignCenter() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ItemAlignStretch : ItemAlign
|
||||
{
|
||||
public ItemAlignStretch() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ItemAlignBaseline : ItemAlign
|
||||
{
|
||||
public ItemAlignBaseline() { }
|
||||
}
|
||||
|
||||
[Union(0, typeof(ContentAlignAuto))]
|
||||
[Union(1, typeof(ContentAlignStart))]
|
||||
[Union(2, typeof(ContentAlignEnd))]
|
||||
[Union(3, typeof(ContentAlignCenter))]
|
||||
[Union(4, typeof(ContentAlignStretch))]
|
||||
[Union(5, typeof(ContentAlignSpaceBetween))]
|
||||
[Union(6, typeof(ContentAlignSpaceAround))]
|
||||
[Union(7, typeof(ContentAlignSpaceEvenly))]
|
||||
public abstract class ContentAlign
|
||||
{
|
||||
internal ContentAlign() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ContentAlignAuto : ContentAlign
|
||||
{
|
||||
public ContentAlignAuto() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ContentAlignStart : ContentAlign
|
||||
{
|
||||
public ContentAlignStart() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ContentAlignEnd : ContentAlign
|
||||
{
|
||||
public ContentAlignEnd() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ContentAlignCenter : ContentAlign
|
||||
{
|
||||
public ContentAlignCenter() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ContentAlignStretch : ContentAlign
|
||||
{
|
||||
public ContentAlignStretch() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ContentAlignSpaceBetween : ContentAlign
|
||||
{
|
||||
public ContentAlignSpaceBetween() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ContentAlignSpaceAround : ContentAlign
|
||||
{
|
||||
public ContentAlignSpaceAround() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ContentAlignSpaceEvenly : ContentAlign
|
||||
{
|
||||
public ContentAlignSpaceEvenly() { }
|
||||
}
|
||||
|
||||
[Union(0, typeof(ThemeToken))]
|
||||
[Union(1, typeof(Rgba))]
|
||||
public abstract class ColorRef
|
||||
{
|
||||
internal ColorRef() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ThemeToken : ColorRef
|
||||
{
|
||||
[Key(0)] public string Name { get; }
|
||||
|
||||
public ThemeToken(string name)
|
||||
{
|
||||
Name = ThingDomain.Present(name, "Name");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class Rgba : ColorRef
|
||||
{
|
||||
[Key(0)] public byte R { get; }
|
||||
[Key(1)] public byte G { get; }
|
||||
[Key(2)] public byte B { get; }
|
||||
[Key(3)] public byte A { get; }
|
||||
|
||||
public Rgba(byte r, byte g, byte b, byte a)
|
||||
{
|
||||
R = r;
|
||||
G = g;
|
||||
B = b;
|
||||
A = a;
|
||||
}
|
||||
}
|
||||
|
||||
[Union(0, typeof(RoleGroup))]
|
||||
[Union(1, typeof(RoleRegion))]
|
||||
[Union(2, typeof(RoleList))]
|
||||
[Union(3, typeof(RoleListItem))]
|
||||
[Union(4, typeof(RoleHeading))]
|
||||
[Union(5, typeof(RoleDialog))]
|
||||
[Union(6, typeof(RoleToolbar))]
|
||||
public abstract class Role
|
||||
{
|
||||
internal Role() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RoleGroup : Role
|
||||
{
|
||||
public RoleGroup() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RoleRegion : Role
|
||||
{
|
||||
public RoleRegion() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RoleList : Role
|
||||
{
|
||||
public RoleList() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RoleListItem : Role
|
||||
{
|
||||
public RoleListItem() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RoleHeading : Role
|
||||
{
|
||||
public RoleHeading() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RoleDialog : Role
|
||||
{
|
||||
public RoleDialog() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RoleToolbar : Role
|
||||
{
|
||||
public RoleToolbar() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class Size2
|
||||
{
|
||||
[Key(0)] public Dimension Width { get; }
|
||||
[Key(1)] public Dimension Height { get; }
|
||||
|
||||
public Size2(Dimension width, Dimension height)
|
||||
{
|
||||
Width = ThingDomain.Present(width, "Width");
|
||||
Height = ThingDomain.Present(height, "Height");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class Edges
|
||||
{
|
||||
[Key(0)] public Dimension Top { get; }
|
||||
[Key(1)] public Dimension Right { get; }
|
||||
[Key(2)] public Dimension Bottom { get; }
|
||||
[Key(3)] public Dimension Left { get; }
|
||||
|
||||
public Edges(Dimension top, Dimension right, Dimension bottom, Dimension left)
|
||||
{
|
||||
Top = ThingDomain.Present(top, "Top");
|
||||
Right = ThingDomain.Present(right, "Right");
|
||||
Bottom = ThingDomain.Present(bottom, "Bottom");
|
||||
Left = ThingDomain.Present(left, "Left");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class BoxStyle
|
||||
{
|
||||
[Key(0)] public Display Display { get; }
|
||||
[Key(1)] public Direction Direction { get; }
|
||||
[Key(2)] public Wrap Wrap { get; }
|
||||
[Key(3)] public Size2 Gap { get; }
|
||||
[Key(4)] public Edges Padding { get; }
|
||||
[Key(5)] public Edges Margin { get; }
|
||||
[Key(6)] public Size2 Size { get; }
|
||||
[Key(7)] public Size2 MinSize { get; }
|
||||
[Key(8)] public Size2 MaxSize { get; }
|
||||
[Key(9)] public Position Position { get; }
|
||||
[Key(10)] public Edges Inset { get; }
|
||||
[Key(11)] public Overflow Overflow { get; }
|
||||
[Key(12)] public ItemAlign AlignItems { get; }
|
||||
[Key(13)] public ItemAlign AlignSelf { get; }
|
||||
[Key(14)] public ContentAlign AlignContent { get; }
|
||||
[Key(15)] public ContentAlign JustifyContent { get; }
|
||||
[Key(16)] public double Grow { get; }
|
||||
[Key(17)] public double Shrink { get; }
|
||||
[Key(18)] public Dimension Basis { 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, IReadOnlyList<Dimension> gridColumns, IReadOnlyList<Dimension> gridRows)
|
||||
{
|
||||
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 = ThingDomain.Present(basis, "Basis");
|
||||
GridColumns = ThingDomain.Items(gridColumns, "GridColumns");
|
||||
GridRows = ThingDomain.Items(gridRows, "GridRows");
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class Border
|
||||
{
|
||||
[Key(0)] public ColorRef Color { get; }
|
||||
[Key(1)] public double Width { get; }
|
||||
|
||||
public Border(ColorRef color, double width)
|
||||
{
|
||||
Color = ThingDomain.Present(color, "Color");
|
||||
Width = width;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class Paint
|
||||
{
|
||||
[Key(0)] public ColorRef? Background { get; }
|
||||
[Key(1)] public Border? Border { get; }
|
||||
[Key(2)] public double Radius { get; }
|
||||
|
||||
public Paint(ColorRef? background, Border? border, double radius)
|
||||
{
|
||||
Background = background;
|
||||
Border = border;
|
||||
Radius = radius;
|
||||
}
|
||||
}
|
||||
297
tests/GameCult.Thing.Contract.Tests/Builders.cs
Normal file
297
tests/GameCult.Thing.Contract.Tests/Builders.cs
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
using GameCult.Thing.Contract;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The values the goldens and the other tests share, ported from crates/thing-contract/tests/common/mod.rs and
|
||||
/// tests/goldens.rs. Every value is built through the package's public constructors; no bytes are spelled here.
|
||||
/// </summary>
|
||||
internal static class Builders
|
||||
{
|
||||
// A Bind's slots, held until a union arm takes them: the arms carry the slots flat, so there is no shared Bind arm.
|
||||
internal sealed record BindParts(StateSource Source, MemberPath Member, Format? Format)
|
||||
{
|
||||
public Bind ToBind() => new(Source, Member, Format);
|
||||
public TextSource Text() => new TextSourceBound(Source, Member, Format);
|
||||
public NumberSource Number() => new NumberSourceBound(Source, Member, Format);
|
||||
public BoolSource Bool() => new BoolSourceBound(Source, Member, Format);
|
||||
public CollectionSource Collection() => new CollectionSourceMember(Source, Member, Format);
|
||||
public ArgValue Arg() => new ArgValueBind(Source, Member, Format);
|
||||
}
|
||||
|
||||
public static TextSource LitText(string value) => new TextLiteral(value);
|
||||
public static BoolSource LitBool(bool value) => new BoolLiteral(value);
|
||||
public static NumberSource LitNumber(double value) => new NumberLiteral(value);
|
||||
|
||||
public static MemberPath Names(params string[] names) =>
|
||||
new(names.Select(name => (MemberStep)new MemberName(name)).ToList());
|
||||
|
||||
public static BindParts RecordBind(MemberPath member, Format? format) =>
|
||||
new(new StateRef("provider.a", "gamecult.thing.test.v1", "row-1"), member, format);
|
||||
|
||||
public static BindParts ItemBind(MemberPath member) => new(new StateSourceItem(), member, null);
|
||||
|
||||
public static CommandRef Command(string id) => new CommandIdRef(id);
|
||||
|
||||
public static NodeCommon Common(string id) => new(id, LitBool(true), null);
|
||||
|
||||
public static Dimension Px(double value) => new DimensionPx(value);
|
||||
|
||||
public static Size2 Size(Dimension width, Dimension height) => new(width, height);
|
||||
|
||||
public static Edges AllEdges(Dimension all) => new(all, all, all, all);
|
||||
|
||||
public static BoxStyle Style(
|
||||
Display? display = null,
|
||||
Direction? direction = null,
|
||||
Wrap? wrap = null,
|
||||
Position? position = null,
|
||||
Overflow? overflow = null,
|
||||
ItemAlign? alignItems = null,
|
||||
ItemAlign? alignSelf = null,
|
||||
ContentAlign? alignContent = null,
|
||||
ContentAlign? justifyContent = null,
|
||||
Dimension? basis = null,
|
||||
List<Dimension>? gridColumns = null,
|
||||
List<Dimension>? gridRows = null) =>
|
||||
new(
|
||||
display ?? new DisplayFlex(),
|
||||
direction ?? new DirectionColumn(),
|
||||
wrap ?? new WrapNoWrap(),
|
||||
Size(Px(4), Px(8)),
|
||||
AllEdges(Px(2)),
|
||||
AllEdges(new DimensionAuto()),
|
||||
Size(new DimensionPercent(100), new DimensionContent()),
|
||||
Size(Px(0), Px(0)),
|
||||
Size(new DimensionAuto(), new DimensionAuto()),
|
||||
position ?? new PositionRelative(),
|
||||
AllEdges(new DimensionAuto()),
|
||||
overflow ?? new OverflowVisible(),
|
||||
alignItems ?? new ItemAlignStretch(),
|
||||
alignSelf ?? new ItemAlignAuto(),
|
||||
alignContent ?? new ContentAlignStart(),
|
||||
justifyContent ?? new ContentAlignSpaceBetween(),
|
||||
1.0,
|
||||
0.0,
|
||||
basis ?? new DimensionAuto(),
|
||||
gridColumns ?? new List<Dimension>(),
|
||||
gridRows ?? new List<Dimension>());
|
||||
|
||||
public static Paint PlainPaint() => new(null, null, 0.0);
|
||||
|
||||
public static Node Boxed(string id, params Node[] children) =>
|
||||
new BoxNode(Common(id), Style(), PlainPaint(), new RoleGroup(), children.ToList());
|
||||
|
||||
public static Node Text(string id, string content) =>
|
||||
new TextNode(Common(id), LitText(content), "body", true, null, false);
|
||||
|
||||
public static CommandInvocation Invocation() => new(
|
||||
"3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b",
|
||||
"thing:surface:provider.a.main",
|
||||
7,
|
||||
"go",
|
||||
new RecordPayload("gamecult.thing.test.go.v1", new byte[] { 0x91, 0x01, 0xff, 0x00 }),
|
||||
new List<CapturedInput>
|
||||
{
|
||||
new("name-input", new TextValue("Ada")),
|
||||
new("level", new NumberValue(0.75)),
|
||||
new("flag", new BoolValue(true)),
|
||||
new("pick", new ChoiceValue("a")),
|
||||
},
|
||||
"renderer.web",
|
||||
"2026-01-01T00:00:00Z",
|
||||
"delegate-1");
|
||||
|
||||
public static CommandInvocation InvocationNamed(string id) => new(
|
||||
id, "thing:surface:provider.a.main", 7, "go",
|
||||
new RecordPayload("gamecult.thing.test.go.v1", new byte[] { 0x91, 0x01, 0xff, 0x00 }),
|
||||
new List<CapturedInput>(), "renderer.web", "2026-01-01T00:00:00Z", null);
|
||||
|
||||
public static List<BindParts> BindAllFormats()
|
||||
{
|
||||
var number = new NumberFormat(2, true, "ms");
|
||||
var affix = new AffixFormat("$", "/s");
|
||||
var indexed = new MemberPath(new List<MemberStep> { new MemberName("items"), new MemberIndex(3) });
|
||||
return new List<BindParts>
|
||||
{
|
||||
RecordBind(indexed, number),
|
||||
RecordBind(Names("elapsed"), new FormatDuration()),
|
||||
RecordBind(Names("at"), new FormatTimestamp()),
|
||||
RecordBind(Names("price"), affix),
|
||||
RecordBind(Names("plain"), null),
|
||||
ItemBind(Names("name")),
|
||||
};
|
||||
}
|
||||
|
||||
public static SurfaceDocument FullSurface()
|
||||
{
|
||||
var binds = BindAllFormats();
|
||||
var boundText = binds[3].Text();
|
||||
var boundBool = binds[4].Bool();
|
||||
var boundNumber = binds[0].Number();
|
||||
|
||||
var painted = new BoxNode(
|
||||
new NodeCommon("painted", new BoolSourceParam("show"), new TextSourceParam("label")),
|
||||
Style(
|
||||
display: new DisplayGrid(),
|
||||
direction: new DirectionRowReverse(),
|
||||
wrap: new WrapWrapReverse(),
|
||||
position: new PositionAbsolute(),
|
||||
overflow: new OverflowScroll(),
|
||||
alignItems: new ItemAlignBaseline(),
|
||||
alignSelf: new ItemAlignCenter(),
|
||||
alignContent: new ContentAlignSpaceAround(),
|
||||
justifyContent: new ContentAlignSpaceEvenly(),
|
||||
gridColumns: new List<Dimension> { new DimensionFr(1), Px(120) },
|
||||
gridRows: new List<Dimension> { new DimensionContent() }),
|
||||
new Paint(new ThemeToken("surface.raised"), new Border(new Rgba(1, 2, 3, 255), 1.5), 4.0),
|
||||
new RoleRegion(),
|
||||
new List<Node> { Text("deep-text", "deep") });
|
||||
|
||||
var repeat = new RepeatNode(
|
||||
Common("rows"),
|
||||
new RecordsSource(null, "gamecult.thing.test.v1", "row-"),
|
||||
Names("id"),
|
||||
new TextNode(Common("row-name"), ItemBind(Names("name")).Text(), "body", false, 2, true));
|
||||
|
||||
var leaves = new List<Node>
|
||||
{
|
||||
new TextNode(Common("t-bound"), boundText, "label", true, null, false),
|
||||
new ImageNode(Common("img"), "icons", "gear", new ImageFitCover(), LitText("a gear")),
|
||||
new ButtonNode(Common("go"), new List<Node> { Text("go-label", "Go") }, Command("go"), boundBool),
|
||||
new ToggleNode(Common("flag"), boundBool, new CommandRefParam("toggle")),
|
||||
new SliderNode(Common("level"), 0.0, 1.0, 0.25, boundNumber, Command("set-level")),
|
||||
new TextInputNode(Common("name-input"), true, new InputKindNumber(), LitText("name"), new TextSourceParam("initial"), Command("rename")),
|
||||
new TextInputNode(Common("title-input"), false, new InputKindText(), LitText("title"), new TextSourceParam("initial"), Command("retitle")),
|
||||
new SelectNode(
|
||||
Common("pick"),
|
||||
new LiteralOptions(new List<SelectOption> { new("a", "Alpha") }),
|
||||
binds[4].ToBind(),
|
||||
Command("pick")),
|
||||
new SelectNode(
|
||||
Common("pick-bound"),
|
||||
new BoundOptions(binds[0].Collection(), Names("id"), Names("name")),
|
||||
binds[5].ToBind(),
|
||||
Command("pick")),
|
||||
repeat,
|
||||
new OverlayNode(Common("menu"), new AnchorNode("go"), false, Command("close"), new List<Node> { Text("menu-item", "one") }),
|
||||
new OverlayNode(Common("dialog"), new AnchorViewport(), true, Command("close"), new List<Node>()),
|
||||
new PlotNode(Common("trend"), binds[1].ToBind(), new PlotMarkArea(), 0.0, null, null, 100.0),
|
||||
new EmbedNode(Common("viewport"), "world.scene", "main", 640.0, 480.0),
|
||||
new UseNode(
|
||||
Common("kv"),
|
||||
new CompositionRef("thing.std", "key-value", 1),
|
||||
new List<Arg> { new("key", new TextArg("k")) }),
|
||||
};
|
||||
|
||||
var deep = Boxed("d4", painted);
|
||||
foreach (var id in new[] { "d3", "d2", "d1" }) deep = Boxed(id, deep);
|
||||
var children = new List<Node> { deep };
|
||||
children.AddRange(leaves);
|
||||
children.Add(new PlotNode(Common("trend-line"), binds[2].ToBind(), new PlotMarkLine(), null, null, null, null));
|
||||
children.Add(new PlotNode(Common("trend-bar"), binds[2].ToBind(), new PlotMarkBar(), null, null, null, null));
|
||||
var fits = new ImageFit[] { new ImageFitContain(), new ImageFitFill(), new ImageFitUnscaled() };
|
||||
for (var index = 0; index < fits.Length; index++)
|
||||
children.Add(new ImageNode(Common($"img-fit-{index}"), "icons", "gear", fits[index], LitText("")));
|
||||
var roles = new Role[] { new RoleList(), new RoleListItem(), new RoleHeading(), new RoleDialog(), new RoleToolbar() };
|
||||
for (var index = 0; index < roles.Length; index++)
|
||||
{
|
||||
var roleStyle = index == 0
|
||||
? Style(direction: new DirectionRow(), alignContent: new ContentAlignAuto(), justifyContent: new ContentAlignStretch())
|
||||
: Style();
|
||||
children.Add(new BoxNode(Common($"role-{index}"), roleStyle, PlainPaint(), roles[index], new List<Node>()));
|
||||
}
|
||||
|
||||
children.Add(new BoxNode(
|
||||
Common("block"),
|
||||
Style(
|
||||
display: new DisplayBlock(),
|
||||
direction: new DirectionColumnReverse(),
|
||||
wrap: new WrapWrap(),
|
||||
overflow: new OverflowClip(),
|
||||
alignContent: new ContentAlignCenter(),
|
||||
justifyContent: new ContentAlignEnd(),
|
||||
alignItems: new ItemAlignEnd(),
|
||||
alignSelf: new ItemAlignStart(),
|
||||
basis: new DimensionContent()),
|
||||
PlainPaint(),
|
||||
new RoleGroup(),
|
||||
new List<Node>()));
|
||||
children.Add(new TextNode(Common("t-param"), new TextSourceParam("title"), "title", true, null, false));
|
||||
children.Add(new ToggleNode(new NodeCommon("literal-number", new BoolSourceParam("v"), null), LitBool(false), Command("toggle")));
|
||||
children.Add(new SliderNode(Common("slider-literal"), -1.0, 1.0, 0.5, LitNumber(0.5), Command("set-level")));
|
||||
children.Add(new SliderNode(Common("slider-param"), 0.0, 10.0, 1.0, new NumberSourceParam("n"), Command("set-level")));
|
||||
children.Add(new RepeatNode(Common("rows-param"), new CollectionSourceParam("rows"), Names("id"), Text("row-param", "r")));
|
||||
|
||||
return new SurfaceDocument(
|
||||
"provider.a",
|
||||
"main",
|
||||
7,
|
||||
"All arms",
|
||||
Boxed("root", children.ToArray()),
|
||||
new List<CommandDecl>
|
||||
{
|
||||
new("go", "gamecult.thing.test.go.v1", new List<string> { "name-input", "level" }, "Go now?"),
|
||||
new("close", "gamecult.thing.test.close.v1", new List<string>(), null),
|
||||
});
|
||||
}
|
||||
|
||||
public static CompositionDocument FullComposition()
|
||||
{
|
||||
var args = new List<Arg>
|
||||
{
|
||||
new("text", new TextArg("t")),
|
||||
new("number", new NumberArg(2.5)),
|
||||
new("bool", new BoolArg(true)),
|
||||
new("bind", RecordBind(Names("x"), null).Arg()),
|
||||
new("collection", new CollectionArg(new CollectionSourceParam("rows"))),
|
||||
new("command", new CommandArg(Command("go"))),
|
||||
new("slot", new SlotArg(new List<Node> { Text("slot-text", "inside"), Boxed("slot-box") })),
|
||||
};
|
||||
var innerUse = new UseNode(Common("inner"), new CompositionRef("thing.std", "card", 2), args);
|
||||
return new CompositionDocument(
|
||||
"provider.a",
|
||||
"panel",
|
||||
3,
|
||||
new List<ParamDecl>
|
||||
{
|
||||
new("title", new ParamKindText()),
|
||||
new("count", new ParamKindNumber()),
|
||||
new("on", new ParamKindBool()),
|
||||
new("source", new ParamKindBind()),
|
||||
new("rows", new ParamKindCollection()),
|
||||
new("act", new ParamKindCommand()),
|
||||
new("body", new ParamKindSlot()),
|
||||
},
|
||||
Boxed("panel-root", Text("panel-title", "t"), innerUse));
|
||||
}
|
||||
|
||||
public static List<(string Name, CommandResult Result)> Results(CommandInvocation invocation) => new()
|
||||
{
|
||||
("result-pending", CommandResult.Pending(invocation, 1, "working")),
|
||||
("result-completed", CommandResult.Completed(invocation, 2, 8, true, null)),
|
||||
("result-completed-open-surface", CommandResult.Completed(invocation, 3, null, false, new OpenSurfaceEffect("thing:surface:provider.a.next"))),
|
||||
("result-completed-open-external", CommandResult.Completed(invocation, 4, 9, true, new OpenExternalEffect("https://example.test/auth", Command("resume")))),
|
||||
("result-denied", CommandResult.Denied(invocation, 5, "not yours", "go")),
|
||||
("result-denied-no-node", CommandResult.Denied(invocation, 6, "not allowed", null)),
|
||||
("result-failed", CommandResult.Failed(invocation, 7, "broke")),
|
||||
};
|
||||
|
||||
public static AssetCatalog FullAssetCatalog() => new(
|
||||
"provider.a",
|
||||
"icons",
|
||||
2,
|
||||
new List<AssetEntry>
|
||||
{
|
||||
new("gear", "image/png", "assets/gear.png", 32, 32),
|
||||
new("bolt", "image/svg+xml", "assets/bolt.svg", 0, 0),
|
||||
});
|
||||
|
||||
public static SurfaceDocument SurfaceOf(string provider, string label) =>
|
||||
new(provider, label, 1, "", Boxed("r"), new List<CommandDecl>());
|
||||
|
||||
public static CompositionDocument CompositionOf(string publisher, string name, long version) =>
|
||||
new(publisher, name, version, new List<ParamDecl>(), Boxed("r"));
|
||||
|
||||
public static AssetCatalog CatalogOf(string provider, string id) => new(provider, id, 1, new List<AssetEntry>());
|
||||
}
|
||||
61
tests/GameCult.Thing.Contract.Tests/DeepTreeTests.cs
Normal file
61
tests/GameCult.Thing.Contract.Tests/DeepTreeTests.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using MessagePack;
|
||||
using static GameCult.Thing.Contract.Tests.Builders;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// CultLib bounds payload nesting at 256 MessagePack containers (the document array is 1). A Box chain costs 3 containers per
|
||||
/// level (union array, slots array, children list), and the deepest leaf's style adds 4 beyond its slots, so a chain of
|
||||
/// 84 boxes nests 3 * 84 + 4 = 256 deep and 85 nests 259. Decoding recurses per level, so the default test thread carries
|
||||
/// the deepest chain the bound admits: this package sets no thread size.
|
||||
/// </summary>
|
||||
public sealed class DeepTreeTests
|
||||
{
|
||||
private const int DeepestAdmitted = 84;
|
||||
|
||||
private static SurfaceDocument Chain(int levels)
|
||||
{
|
||||
var node = Boxed("leaf");
|
||||
for (var level = levels - 1; level >= 1; level--) node = Boxed($"deep-{level}", node);
|
||||
return new SurfaceDocument("provider.a", "deep", 1, "deep", node, new List<CommandDecl>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheDeepestChainTheBoundAdmitsRoundTripsOnTheTestThread()
|
||||
{
|
||||
var chain = Chain(DeepestAdmitted);
|
||||
var bytes = Wire.Write(chain);
|
||||
var decoded = (SurfaceDocument)Wire.Read(typeof(SurfaceDocument), bytes);
|
||||
Assert.That(Convert.ToHexString(Wire.Write(decoded)), Is.EqualTo(Convert.ToHexString(bytes)));
|
||||
|
||||
var depth = 0;
|
||||
for (var node = decoded.Root; node is BoxNode box; node = box.Children.SingleOrDefault()!) depth++;
|
||||
Assert.That(depth, Is.EqualTo(DeepestAdmitted));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OneLevelDeeperIsRefusedByTheBoundOnReadAndOnWrite()
|
||||
{
|
||||
var chain = Chain(DeepestAdmitted + 1);
|
||||
|
||||
var write = Assert.Throws<MessagePackSerializationException>(() => Wire.Write(chain))!;
|
||||
Assert.That(write.Message, Does.Contain("payload nests 257").And.Contain("the limit is 256"));
|
||||
|
||||
// The bytes a hostile sender could produce, past the writer's own check.
|
||||
var bytes = MessagePackSerializer.Serialize(typeof(SurfaceDocument), chain, Wire.Options);
|
||||
var read = Assert.Throws<MessagePackSerializationException>(() => Wire.Read(typeof(SurfaceDocument), bytes))!;
|
||||
Assert.That(read.Message, Does.Contain("payload nests 257").And.Contain("the limit is 256"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AThousandNodesWideRoundTrips()
|
||||
{
|
||||
var children = Enumerable.Range(0, 998).Select(index => Text($"t{index}", "x")).ToList();
|
||||
children.Add(Boxed("tail"));
|
||||
var wide = new SurfaceDocument("provider.a", "wide", 1, "wide", Boxed("root", children.ToArray()), new List<CommandDecl>());
|
||||
var bytes = Wire.Write(wide);
|
||||
var decoded = (SurfaceDocument)Wire.Read(typeof(SurfaceDocument), bytes);
|
||||
Assert.That(((BoxNode)decoded.Root).Children, Has.Count.EqualTo(999));
|
||||
Assert.That(Convert.ToHexString(Wire.Write(decoded)), Is.EqualTo(Convert.ToHexString(bytes)));
|
||||
}
|
||||
}
|
||||
153
tests/GameCult.Thing.Contract.Tests/DomainTests.cs
Normal file
153
tests/GameCult.Thing.Contract.Tests/DomainTests.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
using System.Text.Json;
|
||||
using MessagePack;
|
||||
using static GameCult.Thing.Contract.Tests.Builders;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>The four domains, at every constructor that carries one and at every door that reaches a constructor.</summary>
|
||||
public sealed class DomainTests
|
||||
{
|
||||
private static CommandInvocation Inv() => Invocation();
|
||||
|
||||
// Each domain-carrying field of contract-rs r2, with one out-of-domain value. The expected text is Rust's DomainError Display.
|
||||
private static IEnumerable<TestCaseData> OutOfDomain()
|
||||
{
|
||||
TestCaseData Case(string field, string message, Action build) => new TestCaseData(message, build).SetName($"OutOfDomain({field})");
|
||||
|
||||
yield return Case("CommandResult.Seq", "Seq must be at least 1", () => CommandResult.Pending(Inv(), 0, "m"));
|
||||
yield return Case("CommandResult.Seq min", "Seq must be at least 1", () => CommandResult.Failed(Inv(), int.MinValue, "r"));
|
||||
yield return Case("CompletedOutcome.SourceVersion", "Version must be at least 0", () => new CompletedOutcome(-1, false, null));
|
||||
yield return Case("CommandResult.Completed source version", "Version must be at least 0", () => CommandResult.Completed(Inv(), 1, -1, false, null));
|
||||
yield return Case("CommandInvocation.SurfaceVersion", "Version must be at least 0",
|
||||
() => new CommandInvocation("i", "k", -1, "go", new RecordPayload("s", Array.Empty<byte>()), new List<CapturedInput>(), "c", "t", null));
|
||||
yield return Case("SurfaceDocument.Label empty", "Segment must be non-empty", () => SurfaceOf("p", ""));
|
||||
yield return Case("SurfaceDocument.Label dotted", "Segment must be free of '.'", () => SurfaceOf("p", "a.b"));
|
||||
yield return Case("SurfaceDocument.Version", "Version must be at least 0", () => new SurfaceDocument("p", "l", -1, "t", Boxed("r"), new List<CommandDecl>()));
|
||||
yield return Case("CompositionDocument.Name empty", "Segment must be non-empty", () => CompositionOf("p", "", 1));
|
||||
yield return Case("CompositionDocument.Name dotted", "Segment must be free of '.'", () => CompositionOf("p", "a.b", 1));
|
||||
yield return Case("CompositionDocument.Version", "Version must be at least 0", () => CompositionOf("p", "n", -1));
|
||||
yield return Case("CompositionRef.Name empty", "Segment must be non-empty", () => new CompositionRef("p", "", 1));
|
||||
yield return Case("CompositionRef.Name dotted", "Segment must be free of '.'", () => new CompositionRef("p", "a.b", 1));
|
||||
yield return Case("CompositionRef.Version", "Version must be at least 0", () => new CompositionRef("p", "n", -1));
|
||||
yield return Case("AssetCatalog.CatalogId empty", "Segment must be non-empty", () => CatalogOf("p", ""));
|
||||
yield return Case("AssetCatalog.CatalogId dotted", "Segment must be free of '.'", () => CatalogOf("p", "a.b"));
|
||||
yield return Case("AssetCatalog.Version", "Version must be at least 0", () => new AssetCatalog("p", "c", -1, new List<AssetEntry>()));
|
||||
yield return Case("AssetEntry.Width", "Count must be at least 0", () => new AssetEntry("i", "m", "u", -1, 0));
|
||||
yield return Case("AssetEntry.Height", "Count must be at least 0", () => new AssetEntry("i", "m", "u", 0, int.MinValue));
|
||||
yield return Case("TextNode.MaxLines", "Count must be at least 0",
|
||||
() => new TextNode(Common("t"), LitText("x"), "body", true, -1, false));
|
||||
yield return Case("MemberIndex.Index", "Count must be at least 0", () => new MemberIndex(-1));
|
||||
yield return Case("NumberFormat.Precision", "Count must be at least 0", () => new NumberFormat(-1, false, null));
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(OutOfDomain))]
|
||||
public void OutOfDomainConstructionIsRefusedNamingTheDomainAndBound(string message, Action build)
|
||||
{
|
||||
var error = Assert.Throws<ThingDomainException>(() => build())!;
|
||||
Assert.That(error.Message, Is.EqualTo(message));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BoundaryValuesAreInDomain()
|
||||
{
|
||||
Assert.DoesNotThrow(() => CommandResult.Pending(Inv(), 1, "m"));
|
||||
Assert.DoesNotThrow(() => CommandResult.Pending(Inv(), int.MaxValue, "m"));
|
||||
Assert.DoesNotThrow(() => new CompletedOutcome(0, false, null));
|
||||
Assert.DoesNotThrow(() => new CompletedOutcome(null, false, null));
|
||||
Assert.DoesNotThrow(() => new AssetEntry("i", "m", "u", 0, 0));
|
||||
Assert.DoesNotThrow(() => new TextNode(Common("t"), LitText("x"), "body", true, 0, false));
|
||||
Assert.DoesNotThrow(() => new TextNode(Common("t"), LitText("x"), "body", true, null, false));
|
||||
Assert.DoesNotThrow(() => new MemberIndex(0));
|
||||
Assert.DoesNotThrow(() => new CompositionRef("p", "n", 0));
|
||||
Assert.DoesNotThrow(() => SurfaceOf("p", "a-b_c"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheMessageNeverEchoesTheValue()
|
||||
{
|
||||
var dotted = Assert.Throws<ThingDomainException>(() => SurfaceOf("p", $"{Wire.Canary}.x"))!;
|
||||
Assert.That(dotted.Message, Is.EqualTo("Segment must be free of '.'"));
|
||||
Assert.That(dotted.ToString(), Does.Not.Contain(Wire.Canary));
|
||||
Assert.That((dotted.Domain, dotted.Bound), Is.EqualTo(("Segment", "free of '.'")));
|
||||
|
||||
var seq = Assert.Throws<ThingDomainException>(() => CommandResult.Failed(Inv(), -77, "r"))!;
|
||||
Assert.That(seq.Message, Does.Not.Contain("77"));
|
||||
var version = Assert.Throws<ThingDomainException>(() => CompositionOf("p", "n", -77))!;
|
||||
Assert.That(version.Message, Does.Not.Contain("77"));
|
||||
var count = Assert.Throws<ThingDomainException>(() => new MemberIndex(-77))!;
|
||||
Assert.That(count.Message, Does.Not.Contain("77"));
|
||||
}
|
||||
|
||||
// ---- the doors: every deserializer reaches the one constructor ----
|
||||
|
||||
private static byte[] AssetEntryBytes(int width)
|
||||
{
|
||||
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
var writer = new MessagePackWriter(buffer);
|
||||
writer.WriteArrayHeader(5);
|
||||
writer.Write("gear");
|
||||
writer.Write("image/png");
|
||||
writer.Write(Wire.Canary);
|
||||
writer.Write(width);
|
||||
writer.Write(32);
|
||||
writer.Flush();
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CultLibMessagePackRefusesThroughTheConstructor()
|
||||
{
|
||||
var error = Assert.Throws<MessagePackSerializationException>(
|
||||
() => MessagePackSerializer.Deserialize<AssetEntry>(AssetEntryBytes(-5), Wire.Options))!;
|
||||
Assert.That(Wire.Flatten(error), Does.Contain("Count must be at least 0"));
|
||||
Assert.That(Wire.Flatten(error), Does.Not.Contain(Wire.Canary));
|
||||
Assert.That(MessagePackSerializer.Deserialize<AssetEntry>(AssetEntryBytes(5), Wire.Options).Width, Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MessagePackStandardRefusesThroughTheConstructor()
|
||||
{
|
||||
var error = Assert.Throws<MessagePackSerializationException>(
|
||||
() => MessagePackSerializer.Deserialize<AssetEntry>(AssetEntryBytes(-5), MessagePackSerializerOptions.Standard))!;
|
||||
Assert.That(Wire.Flatten(error), Does.Contain("Count must be at least 0"));
|
||||
Assert.That(Wire.Flatten(error), Does.Not.Contain(Wire.Canary));
|
||||
Assert.That(MessagePackSerializer.Deserialize<AssetEntry>(AssetEntryBytes(5), MessagePackSerializerOptions.Standard).Width, Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SystemTextJsonRefusesThroughTheConstructor()
|
||||
{
|
||||
string Json(int width) => $"{{\"Id\":\"gear\",\"MediaType\":\"image/png\",\"Uri\":\"{Wire.Canary}\",\"Width\":{width},\"Height\":32}}";
|
||||
var error = Assert.Catch(() => JsonSerializer.Deserialize<AssetEntry>(Json(-5)))!;
|
||||
Assert.That(Wire.Flatten(error), Does.Contain("Count must be at least 0"));
|
||||
Assert.That(Wire.Flatten(error), Does.Not.Contain(Wire.Canary));
|
||||
Assert.That(JsonSerializer.Deserialize<AssetEntry>(Json(5))!.Width, Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResultSeqZeroIsRefusedByEveryDoor()
|
||||
{
|
||||
var bytes = Wire.Golden("result-seq-zero.bin");
|
||||
|
||||
var cult = Assert.Throws<MessagePackSerializationException>(() => Wire.Read(typeof(CommandResult), bytes))!;
|
||||
Assert.That(Wire.Flatten(cult), Does.Contain("Seq must be at least 1"));
|
||||
|
||||
var standard = Assert.Throws<MessagePackSerializationException>(
|
||||
() => MessagePackSerializer.Deserialize<CommandResult>(bytes, MessagePackSerializerOptions.Standard))!;
|
||||
Assert.That(Wire.Flatten(standard), Does.Contain("Seq must be at least 1"));
|
||||
Assert.That(Wire.Flatten(standard), Does.Not.Contain(Wire.Canary));
|
||||
|
||||
// System.Text.Json has no constructor it may call on a result: the type is refused outright.
|
||||
Assert.Throws<NotSupportedException>(
|
||||
() => JsonSerializer.Deserialize<CommandResult>("{\"InvocationId\":\"inv-1\",\"Seq\":1,\"Outcome\":null}"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResultsAtSeqOneDecodeThroughTheStandardDoorToo()
|
||||
{
|
||||
var bytes = Wire.Golden("result-pending.bin");
|
||||
var standard = MessagePackSerializer.Deserialize<CommandResult>(bytes, MessagePackSerializerOptions.Standard);
|
||||
Assert.That(standard.Seq, Is.EqualTo(1));
|
||||
Assert.That(standard.Outcome, Is.TypeOf<PendingOutcome>());
|
||||
}
|
||||
}
|
||||
81
tests/GameCult.Thing.Contract.Tests/Doors.cs
Normal file
81
tests/GameCult.Thing.Contract.Tests/Doors.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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) && t != typeof(ThingDomainException)))
|
||||
{
|
||||
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 == typeof(ReadOnlyMemory<byte>)) return new ReadOnlyMemory<byte>(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;
|
||||
}
|
||||
}
|
||||
142
tests/GameCult.Thing.Contract.Tests/FactoriesOnlyTests.cs
Normal file
142
tests/GameCult.Thing.Contract.Tests/FactoriesOnlyTests.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using MessagePack;
|
||||
using static GameCult.Thing.Contract.Tests.Builders;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The package is attacked as an outside consumer would: through its public surface only. A public or init setter would
|
||||
/// admit an object initializer or a with-expression; a record would admit both; a public union constructor would admit a
|
||||
/// subtype nobody declared; a public CommandResult constructor would admit a result no factory built.
|
||||
/// </summary>
|
||||
public sealed class FactoriesOnlyTests
|
||||
{
|
||||
private static readonly Assembly Package = typeof(CommandResult).Assembly;
|
||||
|
||||
private static IEnumerable<Type> PublicTypes() => Package.GetExportedTypes();
|
||||
|
||||
[Test]
|
||||
public void TheCommandResultHasNoPublicConstructor()
|
||||
{
|
||||
Assert.That(typeof(CommandResult).GetConstructors(BindingFlags.Public | BindingFlags.Instance), Is.Empty);
|
||||
Assert.That(typeof(CommandResult).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance), Has.Length.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NoContractTypeHasAPublicOrInitSetterAField_OrIsARecord()
|
||||
{
|
||||
var offenders = new List<string>();
|
||||
foreach (var type in PublicTypes())
|
||||
{
|
||||
foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
var setter = property.SetMethod;
|
||||
if (setter == null) continue;
|
||||
var init = setter.ReturnParameter.GetRequiredCustomModifiers().Contains(typeof(IsExternalInit));
|
||||
if (setter.IsPublic || init || setter.IsAssembly || setter.IsFamily) offenders.Add($"{type.Name}.{property.Name} setter{(init ? " (init)" : "")}");
|
||||
}
|
||||
|
||||
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||
offenders.Add($"{type.Name}.{field.Name} public field");
|
||||
|
||||
if (type.GetMethod("<Clone>$", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) != null)
|
||||
offenders.Add($"{type.Name} is a record (<Clone>$)");
|
||||
}
|
||||
|
||||
Assert.That(offenders, Is.Empty);
|
||||
Assert.That(PublicTypes().Count(), Is.GreaterThan(100), "the scan reached the package's types");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryUnionBaseConstructorIsNonPublic()
|
||||
{
|
||||
var unions = PublicTypes().Where(t => t.IsAbstract && t.GetCustomAttributes<UnionAttribute>(false).Any()).ToList();
|
||||
Assert.That(unions, Has.Count.GreaterThan(25));
|
||||
foreach (var union in unions)
|
||||
{
|
||||
Assert.That(union.GetConstructors(BindingFlags.Public | BindingFlags.Instance), Is.Empty, union.Name);
|
||||
Assert.That(union.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance), Is.Not.Empty, union.Name);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryUnionArmIsSealedAndDeclared()
|
||||
{
|
||||
// The arms are the union attributes' types, each sealed and deriving the union directly: no outside subtype exists.
|
||||
var unions = PublicTypes().Where(t => t.IsAbstract && t.GetCustomAttributes<UnionAttribute>(false).Any());
|
||||
foreach (var union in unions)
|
||||
{
|
||||
var arms = union.GetCustomAttributes<UnionAttribute>(false).ToList();
|
||||
Assert.That(arms.Select(a => a.Key), Is.Unique, union.Name);
|
||||
foreach (var arm in arms)
|
||||
{
|
||||
Assert.That(arm.SubType.IsSealed, Is.True, $"{union.Name}.{arm.SubType.Name}");
|
||||
Assert.That(arm.SubType.BaseType, Is.EqualTo(union), $"{union.Name}.{arm.SubType.Name}");
|
||||
}
|
||||
|
||||
var derived = Package.GetTypes().Where(t => t.BaseType == union).ToList();
|
||||
Assert.That(derived, Is.EquivalentTo(arms.Select(a => a.SubType)), $"{union.Name}: every subtype is a declared arm");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryDocumentCarriesTheCratesSchemaNameAndVersion()
|
||||
{
|
||||
var expected = new Dictionary<Type, (string, string)>
|
||||
{
|
||||
[typeof(SurfaceDocument)] = ("gamecult.thing.surface", "gamecult.thing.surface.v1"),
|
||||
[typeof(CompositionDocument)] = ("gamecult.thing.composition", "gamecult.thing.composition.v1"),
|
||||
[typeof(CommandInvocation)] = ("gamecult.thing.command_invocation", "gamecult.thing.command_invocation.v1"),
|
||||
[typeof(CommandResult)] = ("gamecult.thing.command_result", "gamecult.thing.command_result.v1"),
|
||||
[typeof(AssetCatalog)] = ("gamecult.thing.asset_catalog", "gamecult.thing.asset_catalog.v1"),
|
||||
};
|
||||
foreach (var (type, (name, version)) in expected)
|
||||
{
|
||||
var attribute = type.GetCustomAttribute<GameCult.Caching.CultDocumentAttribute>()!;
|
||||
Assert.That((attribute.SchemaName, attribute.SchemaVersion), Is.EqualTo((name, version)), type.Name);
|
||||
}
|
||||
|
||||
var documents = PublicTypes().Where(t => t.GetCustomAttribute<GameCult.Caching.CultDocumentAttribute>() != null).ToList();
|
||||
Assert.That(documents, Is.EquivalentTo(expected.Keys));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheFactoriesCopyTheInvocationIdAndHoldSeq()
|
||||
{
|
||||
var invocation = InvocationNamed("an-invocation-no-constructor-can-guess");
|
||||
var built = new[]
|
||||
{
|
||||
CommandResult.Pending(invocation, 1, "m"),
|
||||
CommandResult.Completed(invocation, 2, null, false, null),
|
||||
CommandResult.Denied(invocation, 3, "r", null),
|
||||
CommandResult.Failed(invocation, 4, "r"),
|
||||
};
|
||||
for (var index = 0; index < built.Length; index++)
|
||||
{
|
||||
Assert.That(built[index].InvocationId, Is.EqualTo("an-invocation-no-constructor-can-guess"));
|
||||
Assert.That(built[index].Seq, Is.EqualTo(index + 1));
|
||||
}
|
||||
|
||||
Assert.That(built[0].Outcome, Is.TypeOf<PendingOutcome>());
|
||||
Assert.That(((PendingOutcome)built[0].Outcome).Message, Is.EqualTo("m"));
|
||||
Assert.That(built[1].Outcome, Is.TypeOf<CompletedOutcome>());
|
||||
Assert.That(built[2].Outcome, Is.TypeOf<DeniedOutcome>());
|
||||
Assert.That(built[3].Outcome, Is.TypeOf<FailedOutcome>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheFactoriesFillEveryOutcomeSlot()
|
||||
{
|
||||
var invocation = InvocationNamed("i");
|
||||
var effect = new OpenSurfaceEffect("k");
|
||||
var completed = (CompletedOutcome)CommandResult.Completed(invocation, 1, 5, true, effect).Outcome;
|
||||
Assert.That((completed.SourceVersion, completed.ClearDrafts, completed.Effect), Is.EqualTo(((long?)5, true, (Effect?)effect)));
|
||||
var bare = (CompletedOutcome)CommandResult.Completed(invocation, 1, null, false, null).Outcome;
|
||||
Assert.That((bare.SourceVersion, bare.ClearDrafts, bare.Effect), Is.EqualTo(((long?)null, false, (Effect?)null)));
|
||||
var denied = (DeniedOutcome)CommandResult.Denied(invocation, 1, "no", "go").Outcome;
|
||||
Assert.That((denied.Reason, denied.Node), Is.EqualTo(("no", (string?)"go")));
|
||||
var failed = (FailedOutcome)CommandResult.Failed(invocation, 1, "broke").Outcome;
|
||||
Assert.That(failed.Reason, Is.EqualTo("broke"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<BaseOutputPath>obj\bin\</BaseOutputPath>
|
||||
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\CultLib</CultLibRoot>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="NUnit.Framework" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="NUnit" Version="4.2.2" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\packages\GameCult.Thing.Contract\GameCult.Thing.Contract.csproj" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\crates\thing-contract\tests\goldens\*"
|
||||
Link="goldens\%(Filename)%(Extension)"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\tests\thing-contract-parity\*"
|
||||
Link="parity\%(Filename)%(Extension)"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
140
tests/GameCult.Thing.Contract.Tests/GoldenTests.cs
Normal file
140
tests/GameCult.Thing.Contract.Tests/GoldenTests.cs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using static GameCult.Thing.Contract.Tests.Builders;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
public sealed class GoldenTests
|
||||
{
|
||||
private static readonly string[] RefusalGoldens =
|
||||
{
|
||||
"result-thing1-flat", "node-unknown-arm", "outcome-unknown-arm", "result-extra-slot", "result-seq-zero",
|
||||
"surface-version-negative", "surface-label-dotted", "asset-extent-negative", "rgba-channel-out-of-range",
|
||||
};
|
||||
|
||||
private static IEnumerable<TestCaseData> ValueGoldens()
|
||||
{
|
||||
yield return new TestCaseData("surface-all-arms", (Func<object>)FullSurface);
|
||||
yield return new TestCaseData("composition-all-params", (Func<object>)FullComposition);
|
||||
yield return new TestCaseData("invocation-all-inputs", (Func<object>)Invocation);
|
||||
foreach (var (name, _) in Results(Invocation()))
|
||||
{
|
||||
var captured = name;
|
||||
yield return new TestCaseData(captured, (Func<object>)(() => Results(Invocation()).Single(r => r.Name == captured).Result));
|
||||
}
|
||||
yield return new TestCaseData("asset-catalog", (Func<object>)FullAssetCatalog);
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(ValueGoldens))]
|
||||
public void Goldens(string name, Func<object> build)
|
||||
{
|
||||
var value = build();
|
||||
var golden = Wire.Golden($"{name}.bin");
|
||||
|
||||
Assert.That(Convert.ToHexString(Wire.Write(value)), Is.EqualTo(Convert.ToHexString(golden)), $"{name}: the package writes the committed bytes");
|
||||
|
||||
var decoded = Wire.Read(value.GetType(), golden);
|
||||
Assert.That(Convert.ToHexString(Wire.Write(decoded)), Is.EqualTo(Convert.ToHexString(golden)), $"{name}: decoding and re-encoding keeps the bytes");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryBinGoldenIsNamedByACase()
|
||||
{
|
||||
var named = ValueGoldens().Select(c => (string)c.Arguments[0]!).Concat(RefusalGoldens)
|
||||
.OrderBy(n => n, StringComparer.Ordinal).ToArray();
|
||||
var onDisk = Directory.GetFiles(Path.GetDirectoryName(Wire.GoldenPath("x"))!, "*.bin")
|
||||
.Select(f => Path.GetFileNameWithoutExtension(f)).OrderBy(n => n, StringComparer.Ordinal).ToArray();
|
||||
Assert.That(onDisk, Is.EqualTo(named));
|
||||
}
|
||||
|
||||
// ---- refusals ----
|
||||
|
||||
private static string Refused(Type type, string golden)
|
||||
{
|
||||
var bytes = Wire.Golden($"{golden}.bin");
|
||||
var error = Assert.Throws<MessagePackSerializationException>(() => Wire.Read(type, bytes))!;
|
||||
var text = Wire.Flatten(error);
|
||||
Assert.That(text, Does.Not.Contain(Wire.Canary), $"{golden}: the refusal echoes no value");
|
||||
return text;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Thing1FlatMapIsRefused() => Refused(typeof(CommandResult), "result-thing1-flat");
|
||||
|
||||
[TestCase("node-unknown-arm", typeof(SurfaceDocument), "Node", "13")]
|
||||
[TestCase("outcome-unknown-arm", typeof(CommandResult), "Outcome", "4")]
|
||||
public void UnknownArmsAreRefusedNamingUnionAndKey(string golden, Type type, string union, string key)
|
||||
{
|
||||
var text = Refused(type, golden);
|
||||
Assert.That(text, Does.Contain($"{typeof(CommandResult).Namespace}.{union} has no arm for key {key}"));
|
||||
}
|
||||
|
||||
[TestCase("result-seq-zero", typeof(CommandResult), "Seq")]
|
||||
[TestCase("surface-version-negative", typeof(SurfaceDocument), "Version")]
|
||||
[TestCase("surface-label-dotted", typeof(SurfaceDocument), "Segment")]
|
||||
[TestCase("asset-extent-negative", typeof(AssetCatalog), "Count")]
|
||||
public void DomainGoldensAreRefusedNamingTheDomain(string golden, Type type, string domain)
|
||||
{
|
||||
Assert.That(Refused(type, golden), Does.Contain($"{domain} must be"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RgbaChannelAbove255IsRefused()
|
||||
{
|
||||
var bytes = Wire.Golden("rgba-channel-out-of-range.bin");
|
||||
var error = Assert.Throws<MessagePackSerializationException>(
|
||||
() => MessagePackSerializer.Deserialize<Rgba>(bytes, Wire.Options))!;
|
||||
var text = Wire.Flatten(error);
|
||||
Assert.That(text, Does.Not.Contain(Wire.Canary));
|
||||
Assert.That(text, Does.Contain("Rgba"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExtraTrailingSlotIsReadAsPendingSeqOne()
|
||||
{
|
||||
var golden = Wire.Golden("result-extra-slot.bin");
|
||||
var result = (CommandResult)Wire.Read(typeof(CommandResult), golden);
|
||||
Assert.That(result.InvocationId, Is.EqualTo("inv-1"));
|
||||
Assert.That(result.Seq, Is.EqualTo(1));
|
||||
Assert.That(result.Outcome, Is.TypeOf<PendingOutcome>());
|
||||
Assert.That(((PendingOutcome)result.Outcome).Message, Is.EqualTo("working"));
|
||||
// Read, never written back: re-encoding drops the extra slot.
|
||||
Assert.That(Wire.Write(result).Length, Is.LessThan(golden.Length));
|
||||
}
|
||||
|
||||
// ---- the store ----
|
||||
|
||||
// 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()
|
||||
{
|
||||
var scratch = Path.Combine(Path.GetTempPath(), $"thing-contract-store-{Guid.NewGuid():N}.cc");
|
||||
File.Copy(Wire.GoldenPath("surface.cc"), scratch);
|
||||
try
|
||||
{
|
||||
using var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Wire.Registry, ReadOnly = true });
|
||||
|
||||
var cases = new (object Value, string Golden, string Key)[]
|
||||
{
|
||||
(cache.GetAll<SurfaceDocument>().Single(), "surface-all-arms", "thing:surface:provider.a.main"),
|
||||
(cache.GetAll<CompositionDocument>().Single(), "composition-all-params", "thing:composition:provider.a.panel.v3"),
|
||||
(cache.GetAll<CommandInvocation>().Single(), "invocation-all-inputs", "thing:invocation:3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b"),
|
||||
(cache.GetAll<CommandResult>().Single(), "result-pending", "thing:result:3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b.1"),
|
||||
(cache.GetAll<AssetCatalog>().Single(), "asset-catalog", "thing:asset_catalog:provider.a.icons"),
|
||||
};
|
||||
foreach (var (value, golden, key) in cases)
|
||||
{
|
||||
Assert.That(Convert.ToHexString(Wire.Write(value)), Is.EqualTo(Convert.ToHexString(Wire.Golden($"{golden}.bin"))), golden);
|
||||
var recordKey = (string)value.GetType().GetProperty("RecordKey")!.GetValue(value)!;
|
||||
Assert.That(recordKey, Is.EqualTo(key), golden);
|
||||
Assert.That(cache.Get(new CultRecordKey(recordKey)), Is.SameAs(value), $"{golden}: stored under the package's own key");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(scratch);
|
||||
}
|
||||
}
|
||||
}
|
||||
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"));
|
||||
}
|
||||
}
|
||||
66
tests/GameCult.Thing.Contract.Tests/KeyTests.cs
Normal file
66
tests/GameCult.Thing.Contract.Tests/KeyTests.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
using static GameCult.Thing.Contract.Tests.Builders;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
public sealed class KeyTests
|
||||
{
|
||||
[Test]
|
||||
public void KeysAreBuiltFromTheFields()
|
||||
{
|
||||
var invocation = InvocationNamed("an-invocation-no-constructor-can-guess");
|
||||
Assert.That(invocation.RecordKey, Is.EqualTo("thing:invocation:an-invocation-no-constructor-can-guess"));
|
||||
Assert.That(CommandResult.Failed(invocation, 3, "r").RecordKey, Is.EqualTo("thing:result:an-invocation-no-constructor-can-guess.3"));
|
||||
Assert.That(SurfaceOf("p", "main").RecordKey, Is.EqualTo("thing:surface:p.main"));
|
||||
Assert.That(CompositionOf("pub", "card", 2).RecordKey, Is.EqualTo("thing:composition:pub.card.v2"));
|
||||
Assert.That(CatalogOf("p", "icons").RecordKey, Is.EqualTo("thing:asset_catalog:p.icons"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void KeysAreInjective()
|
||||
{
|
||||
Assert.That(SurfaceOf("a.b", "c").RecordKey, Is.EqualTo("thing:surface:a.b.c"));
|
||||
Assert.Throws<ThingDomainException>(() => SurfaceOf("a", "b.c"));
|
||||
Assert.That(SurfaceOf("a", "c").RecordKey, Is.Not.EqualTo(SurfaceOf("a.b", "c").RecordKey));
|
||||
Assert.That(SurfaceOf("a.b", "c").RecordKey, Is.Not.EqualTo(SurfaceOf("a", "bc").RecordKey));
|
||||
|
||||
Assert.That(CompositionOf("a.b", "c", 1).RecordKey, Is.EqualTo("thing:composition:a.b.c.v1"));
|
||||
Assert.That(CompositionOf("a.b", "c", 1).RecordKey, Is.Not.EqualTo(CompositionOf("a", "bc", 1).RecordKey));
|
||||
Assert.That(CompositionOf("a", "c", 1).RecordKey, Is.Not.EqualTo(CompositionOf("a", "c", 11).RecordKey));
|
||||
Assert.That(CompositionOf("a", "c", 1).RecordKey, Is.Not.EqualTo(CompositionOf("a", "c", 0).RecordKey));
|
||||
|
||||
Assert.That(CatalogOf("a.b", "c").RecordKey, Is.EqualTo("thing:asset_catalog:a.b.c"));
|
||||
Assert.That(CatalogOf("a.b", "c").RecordKey, Is.Not.EqualTo(CatalogOf("a", "bc").RecordKey));
|
||||
|
||||
var first = CommandResult.Failed(InvocationNamed("x.1"), 2, "r").RecordKey;
|
||||
var second = CommandResult.Failed(InvocationNamed("x"), 12, "r").RecordKey;
|
||||
Assert.That(first, Is.EqualTo("thing:result:x.1.2"));
|
||||
Assert.That(second, Is.EqualTo("thing:result:x.12"));
|
||||
Assert.That(first, Is.Not.EqualTo(second));
|
||||
}
|
||||
|
||||
[TestCase("")]
|
||||
[TestCase(".")]
|
||||
[TestCase("a.b")]
|
||||
public void EveryKeyBuilderRefusesABadSegment(string segment)
|
||||
{
|
||||
void Names(TestDelegate act)
|
||||
{
|
||||
var error = Assert.Throws<ThingDomainException>(act)!;
|
||||
Assert.That(error.Message, Does.StartWith("Segment must be"));
|
||||
}
|
||||
|
||||
Names(() => ThingKeys.SurfaceKey("p", segment));
|
||||
Names(() => ThingKeys.CompositionKey("p", segment, 1));
|
||||
Names(() => ThingKeys.AssetCatalogKey("p", segment));
|
||||
Names(() => SurfaceOf("p", segment));
|
||||
Names(() => CompositionOf("p", segment, 1));
|
||||
Names(() => CatalogOf("p", segment));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void KeyBuildersRefuseOutOfDomainNumbers()
|
||||
{
|
||||
Assert.Throws<ThingDomainException>(() => ThingKeys.CompositionKey("p", "n", -1));
|
||||
Assert.Throws<ThingDomainException>(() => ThingKeys.ResultKey("i", 0));
|
||||
}
|
||||
}
|
||||
142
tests/GameCult.Thing.Contract.Tests/NameSetTests.cs
Normal file
142
tests/GameCult.Thing.Contract.Tests/NameSetTests.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The names a CultCache store records for the package's documents. CultLib's C# reference compares catalog type names
|
||||
/// when a store reopens, so these names are the C# package's to own: tests/thing-contract-parity holds them for every
|
||||
/// runtime package, and they change only in the commit that changes the C# types.
|
||||
/// </summary>
|
||||
public sealed class NameSetTests
|
||||
{
|
||||
private static string ParityPath(string file) => Path.Combine(AppContext.BaseDirectory, "parity", file);
|
||||
|
||||
private static string[] Lines(string file) => File.ReadAllLines(ParityPath(file)).Where(l => l.Length > 0).ToArray();
|
||||
|
||||
[Test]
|
||||
public void TheCatalogEntriesAreTheMembersFile()
|
||||
{
|
||||
var actual = Wire.DocumentTypes.OrderBy(t => Wire.Registry.GetRequired(t).ToCatalogEntry().SchemaId, StringComparer.Ordinal)
|
||||
.SelectMany(t =>
|
||||
{
|
||||
var entry = Wire.Registry.GetRequired(t).ToCatalogEntry();
|
||||
return entry.Members.Select(m => $"{entry.SchemaId}\t{m.Slot}\t{m.MemberName}\t{m.TypeName}");
|
||||
}).ToArray();
|
||||
Assert.That(actual, Is.EqualTo(Lines("catalog-members.tsv")));
|
||||
Assert.That(actual, Has.Length.GreaterThan(20));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ThePublicTypesAreTheTypeNamesFile()
|
||||
{
|
||||
var actual = typeof(CommandResult).Assembly.GetExportedTypes().Select(t => t.FullName!).OrderBy(n => n, StringComparer.Ordinal).ToArray();
|
||||
Assert.That(actual, Is.EqualTo(Lines("type-names.txt")));
|
||||
Assert.That(actual, Has.Length.GreaterThan(150));
|
||||
}
|
||||
|
||||
private static CultSchemaCatalogEntry Persisted(Type document, string memberTypeFrom, string memberTypeTo)
|
||||
{
|
||||
var local = Wire.Registry.GetRequired(document).ToCatalogEntry();
|
||||
return new CultSchemaCatalogEntry
|
||||
{
|
||||
SchemaId = local.SchemaId[..^1] + (local.SchemaId[^1] == '0' ? '1' : '0'),
|
||||
SchemaName = local.SchemaName,
|
||||
SchemaVersion = local.SchemaVersion,
|
||||
ContentHash = local.ContentHash[..^1] + (local.ContentHash[^1] == '0' ? '1' : '0'),
|
||||
CanonicalSchemaJson = local.CanonicalSchemaJson,
|
||||
Members = local.Members.Select(m => new CultSchemaMemberCatalogEntry
|
||||
{
|
||||
Slot = m.Slot,
|
||||
MemberName = m.MemberName,
|
||||
TypeName = m.TypeName == memberTypeFrom ? memberTypeTo : m.TypeName,
|
||||
IsReference = m.IsReference,
|
||||
IsMany = m.IsMany,
|
||||
TargetSchemaName = m.TargetSchemaName,
|
||||
IsName = m.IsName,
|
||||
IndexAlias = m.IndexAlias,
|
||||
}).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
// A persisted entry equal in id and content hash resolves without comparison, so the entries below move both.
|
||||
[Test]
|
||||
public void APersistedCatalogWhoseTypeNameDiffersIsRefusedByTheComparison()
|
||||
{
|
||||
var unchanged = Persisted(typeof(AssetCatalog), "-", "-");
|
||||
Assert.That(Wire.Registry.ResolvePersistedSchemaReport(unchanged.SchemaId, new[] { unchanged }).Kind,
|
||||
Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift), "a moved id with the same names resolves");
|
||||
|
||||
var renamed = Persisted(typeof(AssetCatalog), "System.Int64", "System.Int32");
|
||||
var error = Assert.Throws<InvalidOperationException>(() => Wire.Registry.ResolvePersistedSchema(renamed.SchemaId, new[] { renamed }))!;
|
||||
Assert.That(error.Message, Does.Contain("changed type").And.Contain("System.Int32").And.Contain("System.Int64"));
|
||||
|
||||
var listed = Persisted(typeof(AssetCatalog), "System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.AssetEntry>",
|
||||
"System.Collections.Generic.List<GameCult.Thing.Contract.AssetEntry>");
|
||||
Assert.Throws<InvalidOperationException>(() => Wire.Registry.ResolvePersistedSchema(listed.SchemaId, new[] { listed }),
|
||||
"a store written when Assets was a List is refused");
|
||||
}
|
||||
|
||||
private static int Patch(byte[] bytes, string from, string to)
|
||||
{
|
||||
var f = System.Text.Encoding.UTF8.GetBytes(from);
|
||||
var t = System.Text.Encoding.UTF8.GetBytes(to);
|
||||
Assert.That(t, Has.Length.EqualTo(f.Length));
|
||||
var count = 0;
|
||||
for (var i = 0; i + f.Length <= bytes.Length; i++)
|
||||
{
|
||||
if (!bytes.AsSpan(i, f.Length).SequenceEqual(f)) continue;
|
||||
t.CopyTo(bytes, i);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string Moved(string hex) => hex[..^1] + (hex[^1] == '0' ? '1' : '0');
|
||||
|
||||
// CultCache reopens a store by schema id; an id it does not own is resolved by name and compared member by member.
|
||||
// A store whose recorded id and content hash name another schema and whose type name was changed is refused, while
|
||||
// the same patch without the type name opens: the refusal is the type-name comparison, end to end.
|
||||
[Test]
|
||||
public void AStoreWhoseRecordedTypeNameDiffersIsRefusedOnReopen()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"thing-names-{Guid.NewGuid():N}.cc");
|
||||
try
|
||||
{
|
||||
using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Wire.Registry }))
|
||||
{
|
||||
cache.Commit(b => b.Upsert(Builders.FullAssetCatalog()));
|
||||
cache.FlushAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
var entry = Wire.Registry.GetRequired(typeof(AssetCatalog)).ToCatalogEntry();
|
||||
var written = File.ReadAllBytes(path);
|
||||
|
||||
byte[] Patched(bool typeName)
|
||||
{
|
||||
var bytes = (byte[])written.Clone();
|
||||
Assert.That(Patch(bytes, entry.SchemaId, Moved(entry.SchemaId)), Is.GreaterThan(0), "the schema id was recorded");
|
||||
Assert.That(Patch(bytes, entry.ContentHash, Moved(entry.ContentHash)), Is.GreaterThan(0), "the content hash was recorded");
|
||||
if (typeName) Assert.That(Patch(bytes, "System.Int64", "System.Int32"), Is.GreaterThan(0), "a member type name was recorded");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
int Reopen(byte[] bytes)
|
||||
{
|
||||
File.WriteAllBytes(path, bytes);
|
||||
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Wire.Registry, ReadOnly = true });
|
||||
return cache.GetAll<AssetCatalog>().Count();
|
||||
}
|
||||
|
||||
Assert.That(Reopen(written), Is.EqualTo(1), "the unpatched store reopens");
|
||||
Assert.That(Reopen(Patched(typeName: false)), Is.EqualTo(1), "a moved id and hash with the same names reopens");
|
||||
var error = Assert.Catch(() => Reopen(Patched(typeName: true)))!;
|
||||
Assert.That(Wire.Flatten(error), Does.Contain("changed type").And.Contain("System.Int32").And.Contain("System.Int64"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
81
tests/GameCult.Thing.Contract.Tests/PresenceTests.cs
Normal file
81
tests/GameCult.Thing.Contract.Tests/PresenceTests.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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(150), "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")));
|
||||
var withCanary = Assert.Throws<ThingDomainException>(() => ThingDomain.Items<string>(new List<string> { Wire.Canary, null! }, "Captures"))!;
|
||||
Assert.That(withCanary.Message, Does.Not.Contain(Wire.Canary));
|
||||
}
|
||||
}
|
||||
200
tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs
Normal file
200
tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
using MessagePack;
|
||||
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) =>
|
||||
(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(200));
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnArrayPassedToAConstructorIsCopiedNotKept()
|
||||
{
|
||||
var child = Builders.Text("t", "x");
|
||||
var other = Builders.Text("u", "y");
|
||||
var array = new[] { child };
|
||||
var box = (BoxNode)Builders.Boxed("b", array);
|
||||
array[0] = other;
|
||||
Assert.That(box.Children[0], Is.SameAs(child), "writing the caller's array changed the value");
|
||||
Assert.That(box.Children, Is.Not.SameAs(array));
|
||||
Assert.That(box.Children is Node[], Is.False, "the held list is the caller's array type");
|
||||
var boxed = (BoxNode)Builders.Boxed("c", child, other);
|
||||
Assert.That(boxed.Children, Has.Count.EqualTo(2));
|
||||
|
||||
foreach (var (door, index, element) in ListParameters())
|
||||
{
|
||||
var args = door.ValidArgs();
|
||||
var passed = Array.CreateInstance(element, 1);
|
||||
passed.SetValue(Doors.Make(element), 0);
|
||||
args[index] = passed;
|
||||
var value = door.Call(args)!;
|
||||
var name = door.Parameters[index].Name!.TrimStart('@');
|
||||
var held = (IList)value.GetType().GetProperty(char.ToUpperInvariant(name[0]) + name[1..])!.GetValue(value)!;
|
||||
var first = held[0];
|
||||
passed.SetValue(Doors.Make(element), 0);
|
||||
Assert.That(held[0], Is.SameAs(first), $"{door.Name}: writing the caller's array changed the value");
|
||||
Assert.That(held, Is.Not.SameAs(passed), door.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WalkLists(object? value, int depth, Action<object> onList)
|
||||
{
|
||||
if (value is null || depth > 200 || value is string || value.GetType().IsPrimitive || value is ReadOnlyMemory<byte>) return;
|
||||
if (value is IList list)
|
||||
{
|
||||
onList(list);
|
||||
foreach (var item in list) WalkLists(item, depth + 1, onList);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.GetType().Namespace != "GameCult.Thing.Contract") return;
|
||||
foreach (var property in value.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
WalkLists(property.GetValue(value), depth + 1, onList);
|
||||
}
|
||||
|
||||
private static readonly (string Golden, Type Type)[] DecodedGoldens =
|
||||
{
|
||||
("surface-all-arms", typeof(SurfaceDocument)), ("composition-all-params", typeof(CompositionDocument)),
|
||||
("invocation-all-inputs", typeof(CommandInvocation)), ("asset-catalog", typeof(AssetCatalog)),
|
||||
("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)),
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void NoListOfADecodedGoldenIsWritable()
|
||||
{
|
||||
var lists = 0;
|
||||
foreach (var (golden, type) in DecodedGoldens)
|
||||
WalkLists(Wire.Read(type, Wire.Golden($"{golden}.bin")), 0, list =>
|
||||
{
|
||||
lists++;
|
||||
var l = (IList)list;
|
||||
Assert.That(l.IsReadOnly && l.IsFixedSize, Is.True, $"{golden}: {l.GetType().Name} is writable");
|
||||
Assert.That(l is Array, Is.False, $"{golden}: {l.GetType().Name} is an array");
|
||||
Assert.Throws<NotSupportedException>(() => l.Add(null), golden);
|
||||
if (l.Count > 0) Assert.Throws<NotSupportedException>(() => l[0] = l[0], golden);
|
||||
});
|
||||
Assert.That(lists, Is.GreaterThan(20));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APayloadHoldsACopyOfTheBytesItWasBuiltFrom()
|
||||
{
|
||||
var buffer = new byte[] { 1, 2, 3 };
|
||||
var payload = new RecordPayload("s", buffer);
|
||||
buffer[0] = 9;
|
||||
Assert.That(payload.Bytes.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 }), "writing the caller's buffer changed the payload");
|
||||
|
||||
var shared = new byte[] { 4, 5, 6, 7 };
|
||||
var slice = new RecordPayload("s", new ReadOnlyMemory<byte>(shared, 1, 2));
|
||||
shared[1] = 0;
|
||||
Assert.That(slice.Bytes.ToArray(), Is.EqualTo(new byte[] { 5, 6 }));
|
||||
Assert.That(System.Runtime.InteropServices.MemoryMarshal.TryGetArray(payload.Bytes, out var segment) && ReferenceEquals(segment.Array, buffer), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryEmptyMemoryIsAnEmptyPayloadThatWritesAsRustsEmptyVec()
|
||||
{
|
||||
// Rust's empty Vec<u8> is bin8 of length 0 (c4 00); a RecordPayload is [schema, bytes].
|
||||
var rust = new byte[] { 0x92, 0xA1, (byte)'s', 0xC4, 0x00 };
|
||||
var empties = new Dictionary<string, ReadOnlyMemory<byte>>
|
||||
{
|
||||
["ReadOnlyMemory.Empty"] = ReadOnlyMemory<byte>.Empty,
|
||||
["Memory.Empty"] = Memory<byte>.Empty,
|
||||
["default"] = default(ReadOnlyMemory<byte>),
|
||||
["Array.Empty"] = Array.Empty<byte>(),
|
||||
["new byte[0]"] = new byte[0],
|
||||
};
|
||||
foreach (var (name, memory) in empties)
|
||||
{
|
||||
var payload = new RecordPayload("s", memory);
|
||||
Assert.That(payload.Bytes.Length, Is.EqualTo(0), name);
|
||||
Assert.That(MessagePackSerializer.Serialize(payload, Wire.Options), Is.EqualTo(rust), name);
|
||||
Assert.That(MessagePackSerializer.Serialize(MessagePackSerializer.Deserialize<RecordPayload>(rust, Wire.Options), Wire.Options), Is.EqualTo(rust), name);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ANilPayloadOnTheWireIsRefusedAndAnEmptyOneIsKeptThroughTheWire()
|
||||
{
|
||||
Assert.Throws<MessagePackSerializationException>(() => MessagePackSerializer.Deserialize<RecordPayload>(new byte[] { 0x92, 0xA1, (byte)'s', 0xC0 }, Wire.Options));
|
||||
|
||||
var empty = new CommandInvocation("i", "k", 1, "go", new RecordPayload("s", Array.Empty<byte>()), new List<CapturedInput>(), "c", "t", null);
|
||||
var decoded = (CommandInvocation)Wire.Read(typeof(CommandInvocation), Wire.Write(empty));
|
||||
Assert.That(decoded.Payload.Bytes.IsEmpty, Is.True);
|
||||
Assert.That(Wire.Write(decoded), Is.EqualTo(Wire.Write(empty)));
|
||||
}
|
||||
}
|
||||
64
tests/GameCult.Thing.Contract.Tests/UnitArmTests.cs
Normal file
64
tests/GameCult.Thing.Contract.Tests/UnitArmTests.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using System.Reflection;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A unit arm is its arm type: there is one way to build it (<c>new ImageFitFill()</c>) and one way to tell it (a type
|
||||
/// test). No union base exposes a static instance, so no code can compare by reference and see a decoded arm differ.
|
||||
/// </summary>
|
||||
public sealed class UnitArmTests
|
||||
{
|
||||
private static IEnumerable<Type> Unions() =>
|
||||
typeof(CommandResult).Assembly.GetExportedTypes().Where(t => t.IsAbstract && t.GetCustomAttributes<UnionAttribute>(false).Any());
|
||||
|
||||
private static IEnumerable<(Type Union, UnionAttribute Arm)> UnitArms() =>
|
||||
Unions().SelectMany(u => u.GetCustomAttributes<UnionAttribute>(false).Select(a => (u, a)))
|
||||
.Where(x => x.a.SubType.GetConstructor(Type.EmptyTypes) != null);
|
||||
|
||||
[Test]
|
||||
public void EveryDecodedUnitArmIsItsArmType()
|
||||
{
|
||||
var arms = UnitArms().ToList();
|
||||
Assert.That(arms, Has.Count.EqualTo(58));
|
||||
foreach (var (union, arm) in arms)
|
||||
{
|
||||
var bytes = new byte[] { 0x92, (byte)arm.Key, 0x90 };
|
||||
var read = MessagePackSerializer.Deserialize(union, bytes, Wire.Options);
|
||||
Assert.That(read, Is.TypeOf(arm.SubType), $"{union.Name} key {arm.Key}");
|
||||
Assert.That(MessagePackSerializer.Serialize(union, read, Wire.Options), Is.EqualTo(bytes), $"{union.Name} key {arm.Key}");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NoUnionBaseExposesAStaticMemberOfItsOwnKind()
|
||||
{
|
||||
var offenders = new List<string>();
|
||||
var unions = Unions().ToList();
|
||||
Assert.That(unions, Has.Count.GreaterThan(25));
|
||||
foreach (var union in unions)
|
||||
{
|
||||
foreach (var member in union.GetMembers(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
var type = member switch
|
||||
{
|
||||
PropertyInfo p => p.PropertyType,
|
||||
FieldInfo f => f.FieldType,
|
||||
MethodInfo m => m.ReturnType,
|
||||
_ => null,
|
||||
};
|
||||
if (type != null && type.IsAssignableTo(union)) offenders.Add($"{union.Name}.{member.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.That(offenders, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ConstructingAUnitArmWritesAnEmptySlotArray()
|
||||
{
|
||||
// [key, []]: array(2), the key, array(0).
|
||||
Assert.That(MessagePackSerializer.Serialize<ImageFit>(new ImageFitFill(), Wire.Options), Is.EqualTo(new byte[] { 0x92, 0x02, 0x90 }));
|
||||
Assert.That(MessagePackSerializer.Serialize<InputKind>(new InputKindNumber(), Wire.Options), Is.EqualTo(new byte[] { 0x92, 0x01, 0x90 }));
|
||||
}
|
||||
}
|
||||
40
tests/GameCult.Thing.Contract.Tests/Wire.cs
Normal file
40
tests/GameCult.Thing.Contract.Tests/Wire.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Thing.Contract.Tests;
|
||||
|
||||
/// <summary>The one way the tests reach the wire: CultLib's document serialization and the committed Rust goldens.</summary>
|
||||
internal static class Wire
|
||||
{
|
||||
public const string Canary = "CANARY-7f3a91c2";
|
||||
|
||||
public static readonly Type[] DocumentTypes =
|
||||
{
|
||||
typeof(SurfaceDocument), typeof(CompositionDocument), typeof(CommandInvocation), typeof(CommandResult), typeof(AssetCatalog),
|
||||
};
|
||||
|
||||
public static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(DocumentTypes);
|
||||
|
||||
public static MessagePackSerializerOptions Options => CultDocumentMessagePackSerialization.OptionsFor(typeof(CommandResult).Assembly);
|
||||
|
||||
public static byte[] Write(object value) => CultDocumentMessagePackSerialization.SerializeUntyped(value, value.GetType(), Registry);
|
||||
|
||||
public static object Read(Type type, byte[] bytes) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, bytes, Registry);
|
||||
|
||||
public static string GoldenPath(string file) => Path.Combine(AppContext.BaseDirectory, "goldens", file);
|
||||
|
||||
public static byte[] Golden(string file)
|
||||
{
|
||||
var bytes = File.ReadAllBytes(GoldenPath(file));
|
||||
Assert.That(bytes, Is.Not.Empty, file);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public static string Flatten(Exception error)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
for (Exception? e = error; e != null; e = e.InnerException) parts.Add($"{e.GetType().Name}: {e.Message}");
|
||||
return string.Join(" | ", parts);
|
||||
}
|
||||
}
|
||||
10
tests/thing-contract-parity/README.md
Normal file
10
tests/thing-contract-parity/README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# thing-contract-parity
|
||||
|
||||
The names the C# package `GameCult.Thing.Contract` gives its types. The C# package owns them because CultLib's C# reference compares catalog type names when a store reopens, and every runtime package is held to the C# names.
|
||||
|
||||
| File | Rows |
|
||||
|---|---|
|
||||
| `catalog-members.tsv` | One row per document slot, `schemaId`, `slot`, `memberName`, `typeName`, as `Wire.Registry`'s catalog entries print them. A list slot reads `System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.X>`. |
|
||||
| `type-names.txt` | The full name of every public type in `GameCult.Thing.Contract`, sorted ordinally. |
|
||||
|
||||
`tests/GameCult.Thing.Contract.Tests/NameSetTests.cs` fails when either file disagrees with the C# types. These files change only in the commit that changes the C# types, and the other runtime packages read them without writing them.
|
||||
27
tests/thing-contract-parity/catalog-members.tsv
Normal file
27
tests/thing-contract-parity/catalog-members.tsv
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 0 InvocationId System.String
|
||||
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 1 SurfaceKey System.String
|
||||
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 2 SurfaceVersion System.Int64
|
||||
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 3 Command System.String
|
||||
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 4 Payload GameCult.Thing.Contract.RecordPayload
|
||||
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 5 Captured System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.CapturedInput>
|
||||
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 6 Client System.String
|
||||
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 7 IssuedAt System.String
|
||||
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 8 Delegation System.String
|
||||
sha256:3d751a6fb07487b0c84cf0ae1c4fe55e5ef0c1656eab41ca3236846a638bdd3f 0 ProviderId System.String
|
||||
sha256:3d751a6fb07487b0c84cf0ae1c4fe55e5ef0c1656eab41ca3236846a638bdd3f 1 CatalogId System.String
|
||||
sha256:3d751a6fb07487b0c84cf0ae1c4fe55e5ef0c1656eab41ca3236846a638bdd3f 2 Version System.Int64
|
||||
sha256:3d751a6fb07487b0c84cf0ae1c4fe55e5ef0c1656eab41ca3236846a638bdd3f 3 Assets System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.AssetEntry>
|
||||
sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 0 Publisher System.String
|
||||
sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 1 Name System.String
|
||||
sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 2 Version System.Int64
|
||||
sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 3 Params System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.ParamDecl>
|
||||
sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 4 Body GameCult.Thing.Contract.Node
|
||||
sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 0 ProviderId System.String
|
||||
sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 1 Label System.String
|
||||
sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 2 Version System.Int64
|
||||
sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 3 Title System.String
|
||||
sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 4 Root GameCult.Thing.Contract.Node
|
||||
sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 5 Commands System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.CommandDecl>
|
||||
sha256:e0273199abadb60da0bd9a7bcb863e131ef08bb2284f7eb729f76159caa369fd 0 InvocationId System.String
|
||||
sha256:e0273199abadb60da0bd9a7bcb863e131ef08bb2284f7eb729f76159caa369fd 1 Seq System.Int32
|
||||
sha256:e0273199abadb60da0bd9a7bcb863e131ef08bb2284f7eb729f76159caa369fd 2 Outcome GameCult.Thing.Contract.Outcome
|
||||
|
168
tests/thing-contract-parity/type-names.txt
Normal file
168
tests/thing-contract-parity/type-names.txt
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
GameCult.Thing.Contract.AffixFormat
|
||||
GameCult.Thing.Contract.Anchor
|
||||
GameCult.Thing.Contract.AnchorNode
|
||||
GameCult.Thing.Contract.AnchorViewport
|
||||
GameCult.Thing.Contract.Arg
|
||||
GameCult.Thing.Contract.ArgValue
|
||||
GameCult.Thing.Contract.ArgValueBind
|
||||
GameCult.Thing.Contract.AssetCatalog
|
||||
GameCult.Thing.Contract.AssetEntry
|
||||
GameCult.Thing.Contract.Bind
|
||||
GameCult.Thing.Contract.BoolArg
|
||||
GameCult.Thing.Contract.BoolLiteral
|
||||
GameCult.Thing.Contract.BoolSource
|
||||
GameCult.Thing.Contract.BoolSourceBound
|
||||
GameCult.Thing.Contract.BoolSourceParam
|
||||
GameCult.Thing.Contract.BoolValue
|
||||
GameCult.Thing.Contract.Border
|
||||
GameCult.Thing.Contract.BoundOptions
|
||||
GameCult.Thing.Contract.BoxNode
|
||||
GameCult.Thing.Contract.BoxStyle
|
||||
GameCult.Thing.Contract.ButtonNode
|
||||
GameCult.Thing.Contract.CapturedInput
|
||||
GameCult.Thing.Contract.ChoiceValue
|
||||
GameCult.Thing.Contract.CollectionArg
|
||||
GameCult.Thing.Contract.CollectionSource
|
||||
GameCult.Thing.Contract.CollectionSourceMember
|
||||
GameCult.Thing.Contract.CollectionSourceParam
|
||||
GameCult.Thing.Contract.ColorRef
|
||||
GameCult.Thing.Contract.CommandArg
|
||||
GameCult.Thing.Contract.CommandDecl
|
||||
GameCult.Thing.Contract.CommandIdRef
|
||||
GameCult.Thing.Contract.CommandInvocation
|
||||
GameCult.Thing.Contract.CommandRef
|
||||
GameCult.Thing.Contract.CommandRefParam
|
||||
GameCult.Thing.Contract.CommandResult
|
||||
GameCult.Thing.Contract.CompletedOutcome
|
||||
GameCult.Thing.Contract.CompositionDocument
|
||||
GameCult.Thing.Contract.CompositionRef
|
||||
GameCult.Thing.Contract.ContentAlign
|
||||
GameCult.Thing.Contract.ContentAlignAuto
|
||||
GameCult.Thing.Contract.ContentAlignCenter
|
||||
GameCult.Thing.Contract.ContentAlignEnd
|
||||
GameCult.Thing.Contract.ContentAlignSpaceAround
|
||||
GameCult.Thing.Contract.ContentAlignSpaceBetween
|
||||
GameCult.Thing.Contract.ContentAlignSpaceEvenly
|
||||
GameCult.Thing.Contract.ContentAlignStart
|
||||
GameCult.Thing.Contract.ContentAlignStretch
|
||||
GameCult.Thing.Contract.DeniedOutcome
|
||||
GameCult.Thing.Contract.Dimension
|
||||
GameCult.Thing.Contract.DimensionAuto
|
||||
GameCult.Thing.Contract.DimensionContent
|
||||
GameCult.Thing.Contract.DimensionFr
|
||||
GameCult.Thing.Contract.DimensionPercent
|
||||
GameCult.Thing.Contract.DimensionPx
|
||||
GameCult.Thing.Contract.Direction
|
||||
GameCult.Thing.Contract.DirectionColumn
|
||||
GameCult.Thing.Contract.DirectionColumnReverse
|
||||
GameCult.Thing.Contract.DirectionRow
|
||||
GameCult.Thing.Contract.DirectionRowReverse
|
||||
GameCult.Thing.Contract.Display
|
||||
GameCult.Thing.Contract.DisplayBlock
|
||||
GameCult.Thing.Contract.DisplayFlex
|
||||
GameCult.Thing.Contract.DisplayGrid
|
||||
GameCult.Thing.Contract.Edges
|
||||
GameCult.Thing.Contract.Effect
|
||||
GameCult.Thing.Contract.EmbedNode
|
||||
GameCult.Thing.Contract.FailedOutcome
|
||||
GameCult.Thing.Contract.Format
|
||||
GameCult.Thing.Contract.FormatDuration
|
||||
GameCult.Thing.Contract.FormatTimestamp
|
||||
GameCult.Thing.Contract.ImageFit
|
||||
GameCult.Thing.Contract.ImageFitContain
|
||||
GameCult.Thing.Contract.ImageFitCover
|
||||
GameCult.Thing.Contract.ImageFitFill
|
||||
GameCult.Thing.Contract.ImageFitUnscaled
|
||||
GameCult.Thing.Contract.ImageNode
|
||||
GameCult.Thing.Contract.InputKind
|
||||
GameCult.Thing.Contract.InputKindNumber
|
||||
GameCult.Thing.Contract.InputKindText
|
||||
GameCult.Thing.Contract.InputValue
|
||||
GameCult.Thing.Contract.ItemAlign
|
||||
GameCult.Thing.Contract.ItemAlignAuto
|
||||
GameCult.Thing.Contract.ItemAlignBaseline
|
||||
GameCult.Thing.Contract.ItemAlignCenter
|
||||
GameCult.Thing.Contract.ItemAlignEnd
|
||||
GameCult.Thing.Contract.ItemAlignStart
|
||||
GameCult.Thing.Contract.ItemAlignStretch
|
||||
GameCult.Thing.Contract.LiteralOptions
|
||||
GameCult.Thing.Contract.MemberIndex
|
||||
GameCult.Thing.Contract.MemberName
|
||||
GameCult.Thing.Contract.MemberPath
|
||||
GameCult.Thing.Contract.MemberStep
|
||||
GameCult.Thing.Contract.Node
|
||||
GameCult.Thing.Contract.NodeCommon
|
||||
GameCult.Thing.Contract.NumberArg
|
||||
GameCult.Thing.Contract.NumberFormat
|
||||
GameCult.Thing.Contract.NumberLiteral
|
||||
GameCult.Thing.Contract.NumberSource
|
||||
GameCult.Thing.Contract.NumberSourceBound
|
||||
GameCult.Thing.Contract.NumberSourceParam
|
||||
GameCult.Thing.Contract.NumberValue
|
||||
GameCult.Thing.Contract.OpenExternalEffect
|
||||
GameCult.Thing.Contract.OpenSurfaceEffect
|
||||
GameCult.Thing.Contract.OptionList
|
||||
GameCult.Thing.Contract.Outcome
|
||||
GameCult.Thing.Contract.Overflow
|
||||
GameCult.Thing.Contract.OverflowClip
|
||||
GameCult.Thing.Contract.OverflowScroll
|
||||
GameCult.Thing.Contract.OverflowVisible
|
||||
GameCult.Thing.Contract.OverlayNode
|
||||
GameCult.Thing.Contract.Paint
|
||||
GameCult.Thing.Contract.ParamDecl
|
||||
GameCult.Thing.Contract.ParamKind
|
||||
GameCult.Thing.Contract.ParamKindBind
|
||||
GameCult.Thing.Contract.ParamKindBool
|
||||
GameCult.Thing.Contract.ParamKindCollection
|
||||
GameCult.Thing.Contract.ParamKindCommand
|
||||
GameCult.Thing.Contract.ParamKindNumber
|
||||
GameCult.Thing.Contract.ParamKindSlot
|
||||
GameCult.Thing.Contract.ParamKindText
|
||||
GameCult.Thing.Contract.PendingOutcome
|
||||
GameCult.Thing.Contract.PlotMark
|
||||
GameCult.Thing.Contract.PlotMarkArea
|
||||
GameCult.Thing.Contract.PlotMarkBar
|
||||
GameCult.Thing.Contract.PlotMarkLine
|
||||
GameCult.Thing.Contract.PlotNode
|
||||
GameCult.Thing.Contract.Position
|
||||
GameCult.Thing.Contract.PositionAbsolute
|
||||
GameCult.Thing.Contract.PositionRelative
|
||||
GameCult.Thing.Contract.RecordPayload
|
||||
GameCult.Thing.Contract.RecordsSource
|
||||
GameCult.Thing.Contract.RepeatNode
|
||||
GameCult.Thing.Contract.Rgba
|
||||
GameCult.Thing.Contract.Role
|
||||
GameCult.Thing.Contract.RoleDialog
|
||||
GameCult.Thing.Contract.RoleGroup
|
||||
GameCult.Thing.Contract.RoleHeading
|
||||
GameCult.Thing.Contract.RoleList
|
||||
GameCult.Thing.Contract.RoleListItem
|
||||
GameCult.Thing.Contract.RoleRegion
|
||||
GameCult.Thing.Contract.RoleToolbar
|
||||
GameCult.Thing.Contract.SelectNode
|
||||
GameCult.Thing.Contract.SelectOption
|
||||
GameCult.Thing.Contract.Size2
|
||||
GameCult.Thing.Contract.SliderNode
|
||||
GameCult.Thing.Contract.SlotArg
|
||||
GameCult.Thing.Contract.StateRef
|
||||
GameCult.Thing.Contract.StateSource
|
||||
GameCult.Thing.Contract.StateSourceItem
|
||||
GameCult.Thing.Contract.SurfaceDocument
|
||||
GameCult.Thing.Contract.TextArg
|
||||
GameCult.Thing.Contract.TextInputNode
|
||||
GameCult.Thing.Contract.TextLiteral
|
||||
GameCult.Thing.Contract.TextNode
|
||||
GameCult.Thing.Contract.TextSource
|
||||
GameCult.Thing.Contract.TextSourceBound
|
||||
GameCult.Thing.Contract.TextSourceParam
|
||||
GameCult.Thing.Contract.TextValue
|
||||
GameCult.Thing.Contract.ThemeToken
|
||||
GameCult.Thing.Contract.ThingDomain
|
||||
GameCult.Thing.Contract.ThingDomainException
|
||||
GameCult.Thing.Contract.ThingKeys
|
||||
GameCult.Thing.Contract.ToggleNode
|
||||
GameCult.Thing.Contract.UseNode
|
||||
GameCult.Thing.Contract.Wrap
|
||||
GameCult.Thing.Contract.WrapNoWrap
|
||||
GameCult.Thing.Contract.WrapWrap
|
||||
GameCult.Thing.Contract.WrapWrapReverse
|
||||
Loading…
Reference in a new issue