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