thing-contract-ts: every codec exported and held to its reference type name; every union to its own name in refusals

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 09:28:35 +02:00
commit aeede043ed
3 changed files with 93 additions and 47 deletions

View file

@ -23,22 +23,22 @@ const required = <TValue>(codec: Codec<TValue | null>): Codec<TValue> => codec a
// source.rs
export const paramName = codecs.slots<T.ParamName>({ 0: ["name", string] }, named("ParamName"));
const textLiteral = codecs.slots<T.TextLiteral>({ 0: ["value", string] }, named("TextLiteral"));
const numberLiteral = codecs.slots<T.NumberLiteral>({ 0: ["value", float64] }, named("NumberLiteral"));
const boolLiteral = codecs.slots<T.BoolLiteral>({ 0: ["value", boolean] }, named("BoolLiteral"));
export const textLiteral = codecs.slots<T.TextLiteral>({ 0: ["value", string] }, named("TextLiteral"));
export const numberLiteral = codecs.slots<T.NumberLiteral>({ 0: ["value", float64] }, named("NumberLiteral"));
export const boolLiteral = codecs.slots<T.BoolLiteral>({ 0: ["value", boolean] }, named("BoolLiteral"));
const memberName = codecs.slots<T.MemberName>({ 0: ["name", string] }, named("MemberName"));
const memberIndex = codecs.slots<T.MemberIndex>({ 0: ["index", count] }, named("MemberIndex"));
export const memberName = codecs.slots<T.MemberName>({ 0: ["name", string] }, named("MemberName"));
export const memberIndex = codecs.slots<T.MemberIndex>({ 0: ["index", count] }, named("MemberIndex"));
export const memberStep = required(
codecs.union<T.MemberStep>("MemberStep", { 0: ["Name", memberName], 1: ["Index", memberIndex] }, named("MemberStep")),
);
export const memberPath = codecs.slots<T.MemberPath>({ 0: ["steps", list(memberStep)] }, named("MemberPath"));
const numberFormat = codecs.slots<T.NumberFormat>(
export const numberFormat = codecs.slots<T.NumberFormat>(
{ 0: ["precision", count], 1: ["percent", boolean], 2: ["unit", nullable(string)] },
named("NumberFormat"),
);
const affixFormat = codecs.slots<T.AffixFormat>({ 0: ["prefix", string], 1: ["suffix", string] }, named("AffixFormat"));
export const affixFormat = codecs.slots<T.AffixFormat>({ 0: ["prefix", string], 1: ["suffix", string] }, named("AffixFormat"));
export const format = required(
codecs.union<T.Format>(
"Format",
@ -47,7 +47,7 @@ export const format = required(
),
);
const stateRef = codecs.slots<T.StateRef>(
export const stateRef = codecs.slots<T.StateRef>(
{ 0: ["provider", nullable(string)], 1: ["schema", string], 2: ["key", string] },
named("StateRef"),
);
@ -81,7 +81,7 @@ export const boolSource = required(
),
);
const recordsSource = codecs.slots<T.RecordsSource>(
export const recordsSource = codecs.slots<T.RecordsSource>(
{ 0: ["provider", nullable(string)], 1: ["schema", string], 2: ["keyPrefix", string] },
named("RecordsSource"),
);
@ -94,7 +94,7 @@ export const collectionSource = required(
);
// style.rs
const scalar = codecs.slots<T.Scalar>({ 0: ["value", float64] }, named("Scalar"));
export const scalar = codecs.slots<T.Scalar>({ 0: ["value", float64] }, named("Scalar"));
export const dimension = required(
codecs.union<T.Dimension>(
"Dimension",
@ -182,7 +182,7 @@ export const boxStyle = codecs.slots<T.BoxStyle>(
},
named("BoxStyle"),
);
const themeToken = codecs.slots<T.ThemeToken>({ 0: ["name", string] }, named("ThemeToken"));
export const themeToken = codecs.slots<T.ThemeToken>({ 0: ["name", string] }, named("ThemeToken"));
export const rgba = codecs.slots<T.Rgba>(
{ 0: ["r", channel], 1: ["g", channel], 2: ["b", channel], 3: ["a", channel] },
named("Rgba"),
@ -190,7 +190,7 @@ export const rgba = codecs.slots<T.Rgba>(
export const colorRef = required(
codecs.union<T.ColorRef>("ColorRef", { 0: ["Token", themeToken], 1: ["Rgba", rgba] }, named("ColorRef")),
);
const border = codecs.slots<T.Border>({ 0: ["color", colorRef], 1: ["width", float64] }, named("Border"));
export const border = codecs.slots<T.Border>({ 0: ["color", colorRef], 1: ["width", float64] }, named("Border"));
export const paint = codecs.slots<T.Paint>(
{ 0: ["background", nullable(colorRef)], 1: ["border", nullable(border)], 2: ["radius", float64] },
named("Paint"),
@ -216,7 +216,7 @@ export const commandDecl = codecs.slots<T.CommandDecl>(
{ 0: ["id", string], 1: ["payloadSchema", string], 2: ["captures", list(string)], 3: ["confirm", nullable(string)] },
named("CommandDecl"),
);
const commandIdRef = codecs.slots<T.CommandIdRef>({ 0: ["id", string] }, named("CommandIdRef"));
export const commandIdRef = codecs.slots<T.CommandIdRef>({ 0: ["id", string] }, named("CommandIdRef"));
export const commandRef = required(
codecs.union<T.CommandRef>("CommandRef", { 0: ["Id", commandIdRef], 1: ["Param", paramName] }, named("CommandRef")),
);
@ -224,10 +224,10 @@ export const recordPayload = codecs.slots<T.RecordPayload>(
{ 0: ["schema", string], 1: ["bytes", bytes] },
named("RecordPayload"),
);
const textValue = codecs.slots<T.TextValue>({ 0: ["value", string] }, named("TextValue"));
const numberValue = codecs.slots<T.NumberValue>({ 0: ["value", float64] }, named("NumberValue"));
const boolValue = codecs.slots<T.BoolValue>({ 0: ["value", boolean] }, named("BoolValue"));
const choiceValue = codecs.slots<T.ChoiceValue>({ 0: ["value", string] }, named("ChoiceValue"));
export const textValue = codecs.slots<T.TextValue>({ 0: ["value", string] }, named("TextValue"));
export const numberValue = codecs.slots<T.NumberValue>({ 0: ["value", float64] }, named("NumberValue"));
export const boolValue = codecs.slots<T.BoolValue>({ 0: ["value", boolean] }, named("BoolValue"));
export const choiceValue = codecs.slots<T.ChoiceValue>({ 0: ["value", string] }, named("ChoiceValue"));
export const inputValue = required(
codecs.union<T.InputValue>(
"InputValue",
@ -255,9 +255,9 @@ export const commandInvocation = codecs.slots<T.CommandInvocation>(
);
// result.rs
const pendingOutcome = codecs.slots<T.PendingOutcome>({ 0: ["message", string] }, named("PendingOutcome"));
const openSurfaceEffect = codecs.slots<T.OpenSurfaceEffect>({ 0: ["key", string] }, named("OpenSurfaceEffect"));
const openExternalEffect = codecs.slots<T.OpenExternalEffect>(
export const pendingOutcome = codecs.slots<T.PendingOutcome>({ 0: ["message", string] }, named("PendingOutcome"));
export const openSurfaceEffect = codecs.slots<T.OpenSurfaceEffect>({ 0: ["key", string] }, named("OpenSurfaceEffect"));
export const openExternalEffect = codecs.slots<T.OpenExternalEffect>(
{ 0: ["url", string], 1: ["resume", commandRef] },
named("OpenExternalEffect"),
);
@ -268,15 +268,15 @@ export const effect = required(
named("Effect"),
),
);
const completedOutcome = codecs.slots<T.CompletedOutcome>(
export const completedOutcome = codecs.slots<T.CompletedOutcome>(
{ 0: ["sourceVersion", nullable(version)], 1: ["clearDrafts", boolean], 2: ["effect", nullable(effect)] },
named("CompletedOutcome"),
);
const deniedOutcome = codecs.slots<T.DeniedOutcome>(
export const deniedOutcome = codecs.slots<T.DeniedOutcome>(
{ 0: ["reason", string], 1: ["node", nullable(string)] },
named("DeniedOutcome"),
);
const failedOutcome = codecs.slots<T.FailedOutcome>({ 0: ["reason", string] }, named("FailedOutcome"));
export const failedOutcome = codecs.slots<T.FailedOutcome>({ 0: ["reason", string] }, named("FailedOutcome"));
export const outcome = required(
codecs.union<T.Outcome>(
"Outcome",
@ -294,11 +294,11 @@ export const nodeCommon = codecs.slots<T.NodeCommon>(
// Node closes its own cycle here and nowhere else.
export const node: Codec<T.Node> = lazy(() => nodeUnion);
const boxNode = codecs.slots<T.BoxNode>(
export const boxNode = codecs.slots<T.BoxNode>(
{ 0: ["common", nodeCommon], 1: ["style", boxStyle], 2: ["paint", paint], 3: ["role", role], 4: ["children", list(node)] },
named("BoxNode"),
);
const textNode = codecs.slots<T.TextNode>(
export const textNode = codecs.slots<T.TextNode>(
{
0: ["common", nodeCommon],
1: ["content", textSource],
@ -316,19 +316,19 @@ export const imageFit = required(
named("ImageFit"),
),
);
const imageNode = codecs.slots<T.ImageNode>(
export const imageNode = codecs.slots<T.ImageNode>(
{ 0: ["common", nodeCommon], 1: ["catalog", string], 2: ["asset", string], 3: ["fit", imageFit], 4: ["alt", textSource] },
named("ImageNode"),
);
const buttonNode = codecs.slots<T.ButtonNode>(
export const buttonNode = codecs.slots<T.ButtonNode>(
{ 0: ["common", nodeCommon], 1: ["content", list(node)], 2: ["onActivate", commandRef], 3: ["enabled", boolSource] },
named("ButtonNode"),
);
const toggleNode = codecs.slots<T.ToggleNode>(
export const toggleNode = codecs.slots<T.ToggleNode>(
{ 0: ["common", nodeCommon], 1: ["checked", boolSource], 2: ["onChange", commandRef] },
named("ToggleNode"),
);
const sliderNode = codecs.slots<T.SliderNode>(
export const sliderNode = codecs.slots<T.SliderNode>(
{
0: ["common", nodeCommon],
1: ["min", float64],
@ -342,7 +342,7 @@ const sliderNode = codecs.slots<T.SliderNode>(
export const inputKind = required(
codecs.union<T.InputKind>("InputKind", { 0: ["Text", unit], 1: ["Number", unit] }, named("InputKind")),
);
const textInputNode = codecs.slots<T.TextInputNode>(
export const textInputNode = codecs.slots<T.TextInputNode>(
{
0: ["common", nodeCommon],
1: ["multiline", boolean],
@ -353,28 +353,28 @@ const textInputNode = codecs.slots<T.TextInputNode>(
},
named("TextInputNode"),
);
const selectOption = codecs.slots<T.SelectOption>({ 0: ["value", string], 1: ["label", string] }, named("SelectOption"));
const literalOptions = codecs.slots<T.LiteralOptions>({ 0: ["options", list(selectOption)] }, named("LiteralOptions"));
const boundOptions = codecs.slots<T.BoundOptions>(
export const selectOption = codecs.slots<T.SelectOption>({ 0: ["value", string], 1: ["label", string] }, named("SelectOption"));
export const literalOptions = codecs.slots<T.LiteralOptions>({ 0: ["options", list(selectOption)] }, named("LiteralOptions"));
export const boundOptions = codecs.slots<T.BoundOptions>(
{ 0: ["collection", collectionSource], 1: ["valueMember", memberPath], 2: ["labelMember", memberPath] },
named("BoundOptions"),
);
export const optionList = required(
codecs.union<T.OptionList>("OptionList", { 0: ["Literal", literalOptions], 1: ["Bound", boundOptions] }, named("OptionList")),
);
const selectNode = codecs.slots<T.SelectNode>(
export const selectNode = codecs.slots<T.SelectNode>(
{ 0: ["common", nodeCommon], 1: ["options", optionList], 2: ["value", bind], 3: ["onChange", commandRef] },
named("SelectNode"),
);
const repeatNode = codecs.slots<T.RepeatNode>(
export const repeatNode = codecs.slots<T.RepeatNode>(
{ 0: ["common", nodeCommon], 1: ["collection", collectionSource], 2: ["keyMember", memberPath], 3: ["template", node] },
named("RepeatNode"),
);
const anchorNode = codecs.slots<T.AnchorNode>({ 0: ["node", string] }, named("AnchorNode"));
export const anchorNode = codecs.slots<T.AnchorNode>({ 0: ["node", string] }, named("AnchorNode"));
export const anchor = required(
codecs.union<T.Anchor>("Anchor", { 0: ["Viewport", unit], 1: ["Node", anchorNode] }, named("Anchor")),
);
const overlayNode = codecs.slots<T.OverlayNode>(
export const overlayNode = codecs.slots<T.OverlayNode>(
{
0: ["common", nodeCommon],
1: ["anchor", anchor],
@ -387,7 +387,7 @@ const overlayNode = codecs.slots<T.OverlayNode>(
export const plotMark = required(
codecs.union<T.PlotMark>("PlotMark", { 0: ["Line", unit], 1: ["Bar", unit], 2: ["Area", unit] }, named("PlotMark")),
);
const plotNode = codecs.slots<T.PlotNode>(
export const plotNode = codecs.slots<T.PlotNode>(
{
0: ["common", nodeCommon],
1: ["series", bind],
@ -399,7 +399,7 @@ const plotNode = codecs.slots<T.PlotNode>(
},
named("PlotNode"),
);
const embedNode = codecs.slots<T.EmbedNode>(
export const embedNode = codecs.slots<T.EmbedNode>(
{
0: ["common", nodeCommon],
1: ["slotKind", string],
@ -413,12 +413,12 @@ export const compositionRef = codecs.slots<T.CompositionRef>(
{ 0: ["publisher", string], 1: ["name", segment], 2: ["version", version] },
named("CompositionRef"),
);
const textArg = codecs.slots<T.TextArg>({ 0: ["value", string] }, named("TextArg"));
const numberArg = codecs.slots<T.NumberArg>({ 0: ["value", float64] }, named("NumberArg"));
const boolArg = codecs.slots<T.BoolArg>({ 0: ["value", boolean] }, named("BoolArg"));
const collectionArg = codecs.slots<T.CollectionArg>({ 0: ["source", collectionSource] }, named("CollectionArg"));
const commandArg = codecs.slots<T.CommandArg>({ 0: ["command", commandRef] }, named("CommandArg"));
const slotArg = codecs.slots<T.SlotArg>({ 0: ["nodes", list(node)] }, named("SlotArg"));
export const textArg = codecs.slots<T.TextArg>({ 0: ["value", string] }, named("TextArg"));
export const numberArg = codecs.slots<T.NumberArg>({ 0: ["value", float64] }, named("NumberArg"));
export const boolArg = codecs.slots<T.BoolArg>({ 0: ["value", boolean] }, named("BoolArg"));
export const collectionArg = codecs.slots<T.CollectionArg>({ 0: ["source", collectionSource] }, named("CollectionArg"));
export const commandArg = codecs.slots<T.CommandArg>({ 0: ["command", commandRef] }, named("CommandArg"));
export const slotArg = codecs.slots<T.SlotArg>({ 0: ["nodes", list(node)] }, named("SlotArg"));
export const argValue = required(
codecs.union<T.ArgValue>(
"ArgValue",
@ -435,7 +435,7 @@ export const argValue = required(
),
);
export const arg = codecs.slots<T.Arg>({ 0: ["name", string], 1: ["value", argValue] }, named("Arg"));
const useNode = codecs.slots<T.UseNode>(
export const useNode = codecs.slots<T.UseNode>(
{ 0: ["common", nodeCommon], 1: ["composition", compositionRef], 2: ["args", list(arg)] },
named("UseNode"),
);
@ -501,7 +501,7 @@ export const compositionDocument = codecs.slots<T.CompositionDocument>(
},
named("CompositionDocument"),
);
const assetEntry = codecs.slots<T.AssetEntry>(
export const assetEntry = codecs.slots<T.AssetEntry>(
{ 0: ["id", string], 1: ["mediaType", string], 2: ["uri", string], 3: ["width", count], 4: ["height", count] },
named("AssetEntry"),
);

View file

@ -0,0 +1,37 @@
// Every codec of the package is named GameCult.Thing.Contract.<RustName>, the reference's full type name, and every union
// names itself in its refusals. The exports are the lowerCamel of the Rust type names, so the test reads the expected name
// off the export.
import assert from "node:assert/strict";
import test from "node:test";
import { codecs } from "@gamecult/thing-contract";
const NS = "GameCult.Thing.Contract.";
const entries = Object.entries(codecs) as [string, { typeName?: string; decode(raw: unknown): unknown }][];
const rustName = (key: string) => key[0].toUpperCase() + key.slice(1);
test("the package exports ninety-five codecs", () => {
assert.equal(entries.length, 95);
});
test("every codec carries the reference's full type name", () => {
for (const [key, codec] of entries) assert.equal(codec.typeName, `${NS}${rustName(key)}`, key);
});
test("every union refuses an unknown arm by its own name, and the twenty-nine unions are all of them", () => {
const unions: string[] = [];
for (const [key, codec] of entries) {
let message: string;
try {
codec.decode([2 ** 30, []]);
continue;
} catch (error) {
message = (error as Error).message;
}
const named = /^Union (\w+): unknown arm key 1073741824 \(declared keys: [-\d, ]+\)\.$/.exec(message);
if (named === null) continue;
unions.push(key);
assert.equal(named[1], rustName(key), key);
}
assert.equal(unions.length, 29);
});

View file

@ -16,6 +16,14 @@ const NS = "GameCult.Thing.Contract.";
const STRING = "System.String";
const list = (item: string) => `System.Collections.Generic.List<${NS}${item}>`;
const rustName: Record<string, string> = {
"gamecult.thing.surface": "SurfaceDocument",
"gamecult.thing.composition": "CompositionDocument",
"gamecult.thing.command_invocation": "CommandInvocation",
"gamecult.thing.command_result": "CommandResult",
"gamecult.thing.asset_catalog": "AssetCatalog",
};
const expected = [
[surfaceDocument, "gamecult.thing.surface", [
"0:ProviderId:" + STRING,
@ -61,6 +69,7 @@ for (const [definition, type, members] of expected) {
assert.equal(definition.type, type);
assert.equal(definition.schemaName, type);
assert.equal(definition.schemaVersion, `${type}.v1`);
assert.equal(definition.codec?.typeName, `${NS}${rustName[type]}`);
assert.deepEqual(
definition.members?.map((member) => `${member.slot}:${member.memberName}:${member.typeName}`),
members,