diff --git a/.gitignore b/.gitignore index 816f622..76c9029 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ packages/eve-browser-lowering/node_modules/ !packages/eve-contracts/** packages/eve-contracts/node_modules/ packages/eve-contracts/*.tgz +!packages/thing-contract/ +!packages/thing-contract/** +packages/thing-contract/node_modules/ +packages/thing-contract/dist/ +packages/thing-contract/dist-test/ !packages/org.gamecult.eve.surface/ !packages/org.gamecult.eve.surface/** packages/org.gamecult.eve.surface/bin~/ diff --git a/packages/thing-contract/package-lock.json b/packages/thing-contract/package-lock.json new file mode 100644 index 0000000..09fe704 --- /dev/null +++ b/packages/thing-contract/package-lock.json @@ -0,0 +1,71 @@ +{ + "name": "@gamecult/thing-contract", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@gamecult/thing-contract", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@gamecult/cultcache-ts": "file:../../../CultLib/packages/cultcache-ts" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + } + }, + "../../../CultLib/packages/cultcache-ts": { + "name": "@gamecult/cultcache-ts", + "version": "0.14.0", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.1.3", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@gamecult/cultcache-ts": { + "resolved": "../../../CultLib/packages/cultcache-ts", + "link": true + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/packages/thing-contract/package.json b/packages/thing-contract/package.json new file mode 100644 index 0000000..5ec159f --- /dev/null +++ b/packages/thing-contract/package.json @@ -0,0 +1,29 @@ +{ + "name": "@gamecult/thing-contract", + "version": "0.1.0", + "description": "The gamecult.thing.* wire types and codecs for TypeScript, held byte for byte to the Rust crate's goldens.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "tsc -p tsconfig.json && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js" + }, + "dependencies": { + "@gamecult/cultcache-ts": "file:../../../CultLib/packages/cultcache-ts" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + }, + "license": "MIT" +} diff --git a/packages/thing-contract/src/codecs.ts b/packages/thing-contract/src/codecs.ts new file mode 100644 index 0000000..40c428a --- /dev/null +++ b/packages/thing-contract/src/codecs.ts @@ -0,0 +1,511 @@ +// One codec per type of crates/thing-contract, built from cultcache-ts's slots, union, list, nullable and lazy at the +// crate's slot and arm keys. Nothing here encodes or decodes by hand, declares a slot default, or checks an arm: the +// library's codecs refuse an unknown arm or a missing slot, and domain.ts holds the five domains. +// +// Every slots and union codec is named GameCult.Thing.Contract., the reference's full type name, because a +// store's schema catalog carries the type name of each document slot and the reference compares them. +// +// Definition order is dependency order. The one cycle is Node (through Box, Button, Overlay, Repeat and Slot), and it +// closes at slot level with `lazy`, never inside a union's arm table. +import { codecs } from "@gamecult/cultcache-ts"; +import type { Codec } from "@gamecult/cultcache-ts"; + +import { channel, count, segment, version } from "./domain.js"; +import type * as T from "./types.js"; + +const { string, boolean, float64, bytes, list, nullable, lazy, unit } = codecs; + +const named = (rustName: string): string => `GameCult.Thing.Contract.${rustName}`; + +// A union codec reads nil as null. A required union slot is typed without the null; a nil there is CultLib's +// nullable-reference rule (the reference reads nil too), not this package's. +const required = (codec: Codec): Codec => codec as Codec; + +// source.rs +export const paramName = codecs.slots({ 0: ["name", string] }, named("ParamName")); +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")); + +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")); + +export const numberFormat = codecs.slots( + { 0: ["precision", count], 1: ["percent", boolean], 2: ["unit", nullable(string)] }, + named("NumberFormat"), +); +export const affixFormat = codecs.slots({ 0: ["prefix", string], 1: ["suffix", string] }, named("AffixFormat")); +export const format = required( + codecs.union( + "Format", + { 0: ["Number", numberFormat], 1: ["Duration", unit], 2: ["Timestamp", unit], 3: ["Affix", affixFormat] }, + named("Format"), + ), +); + +export const stateRef = codecs.slots( + { 0: ["provider", nullable(string)], 1: ["schema", string], 2: ["key", string] }, + named("StateRef"), +); +export const stateSource = required( + codecs.union("StateSource", { 0: ["Record", stateRef], 1: ["Item", unit] }, named("StateSource")), +); +export const bind = codecs.slots( + { 0: ["source", stateSource], 1: ["member", memberPath], 2: ["format", nullable(format)] }, + named("Bind"), +); + +export const textSource = required( + codecs.union( + "TextSource", + { 0: ["Literal", textLiteral], 1: ["Bound", bind], 2: ["Param", paramName] }, + named("TextSource"), + ), +); +export const numberSource = required( + codecs.union( + "NumberSource", + { 0: ["Literal", numberLiteral], 1: ["Bound", bind], 2: ["Param", paramName] }, + named("NumberSource"), + ), +); +export const boolSource = required( + codecs.union( + "BoolSource", + { 0: ["Literal", boolLiteral], 1: ["Bound", bind], 2: ["Param", paramName] }, + named("BoolSource"), + ), +); + +export const recordsSource = codecs.slots( + { 0: ["provider", nullable(string)], 1: ["schema", string], 2: ["keyPrefix", string] }, + named("RecordsSource"), +); +export const collectionSource = required( + codecs.union( + "CollectionSource", + { 0: ["Records", recordsSource], 1: ["Member", bind], 2: ["Param", paramName] }, + named("CollectionSource"), + ), +); + +// style.rs +export const scalar = codecs.slots({ 0: ["value", float64] }, named("Scalar")); +export const dimension = required( + codecs.union( + "Dimension", + { 0: ["Px", scalar], 1: ["Percent", scalar], 2: ["Fr", scalar], 3: ["Auto", unit], 4: ["Content", unit] }, + named("Dimension"), + ), +); +export const size2 = codecs.slots({ 0: ["width", dimension], 1: ["height", dimension] }, named("Size2")); +export const edges = codecs.slots( + { 0: ["top", dimension], 1: ["right", dimension], 2: ["bottom", dimension], 3: ["left", dimension] }, + named("Edges"), +); +export const display = required( + codecs.union("Display", { 0: ["Flex", unit], 1: ["Grid", unit], 2: ["Block", unit] }, named("Display")), +); +export const direction = required( + codecs.union( + "Direction", + { 0: ["Row", unit], 1: ["Column", unit], 2: ["RowReverse", unit], 3: ["ColumnReverse", unit] }, + named("Direction"), + ), +); +export const wrap = required( + codecs.union("Wrap", { 0: ["NoWrap", unit], 1: ["Wrap", unit], 2: ["WrapReverse", unit] }, named("Wrap")), +); +export const position = required( + codecs.union("Position", { 0: ["Relative", unit], 1: ["Absolute", unit] }, named("Position")), +); +export const overflow = required( + codecs.union("Overflow", { 0: ["Visible", unit], 1: ["Clip", unit], 2: ["Scroll", unit] }, named("Overflow")), +); +export const itemAlign = required( + codecs.union( + "ItemAlign", + { + 0: ["Auto", unit], + 1: ["Start", unit], + 2: ["End", unit], + 3: ["Center", unit], + 4: ["Stretch", unit], + 5: ["Baseline", unit], + }, + named("ItemAlign"), + ), +); +export const contentAlign = required( + codecs.union( + "ContentAlign", + { + 0: ["Auto", unit], + 1: ["Start", unit], + 2: ["End", unit], + 3: ["Center", unit], + 4: ["Stretch", unit], + 5: ["SpaceBetween", unit], + 6: ["SpaceAround", unit], + 7: ["SpaceEvenly", unit], + }, + named("ContentAlign"), + ), +); +export const boxStyle = codecs.slots( + { + 0: ["display", display], + 1: ["direction", direction], + 2: ["wrap", wrap], + 3: ["gap", size2], + 4: ["padding", edges], + 5: ["margin", edges], + 6: ["size", size2], + 7: ["minSize", size2], + 8: ["maxSize", size2], + 9: ["position", position], + 10: ["inset", edges], + 11: ["overflow", overflow], + 12: ["alignItems", itemAlign], + 13: ["alignSelf", itemAlign], + 14: ["alignContent", contentAlign], + 15: ["justifyContent", contentAlign], + 16: ["grow", float64], + 17: ["shrink", float64], + 18: ["basis", dimension], + 19: ["gridColumns", list(dimension)], + 20: ["gridRows", list(dimension)], + }, + named("BoxStyle"), +); +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"), +); +export const colorRef = required( + codecs.union("ColorRef", { 0: ["Token", themeToken], 1: ["Rgba", rgba] }, named("ColorRef")), +); +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"), +); +export const role = required( + codecs.union( + "Role", + { + 0: ["Group", unit], + 1: ["Region", unit], + 2: ["List", unit], + 3: ["ListItem", unit], + 4: ["Heading", unit], + 5: ["Dialog", unit], + 6: ["Toolbar", unit], + }, + named("Role"), + ), +); + +// command.rs +export const commandDecl = codecs.slots( + { 0: ["id", string], 1: ["payloadSchema", string], 2: ["captures", list(string)], 3: ["confirm", nullable(string)] }, + named("CommandDecl"), +); +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")), +); +export const recordPayload = codecs.slots( + { 0: ["schema", string], 1: ["bytes", bytes] }, + named("RecordPayload"), +); +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", + { 0: ["Text", textValue], 1: ["Number", numberValue], 2: ["Bool", boolValue], 3: ["Choice", choiceValue] }, + named("InputValue"), + ), +); +export const capturedInput = codecs.slots( + { 0: ["node", string], 1: ["value", inputValue] }, + named("CapturedInput"), +); +export const commandInvocation = codecs.slots( + { + 0: ["invocationId", string], + 1: ["surfaceKey", string], + 2: ["surfaceVersion", version], + 3: ["command", string], + 4: ["payload", recordPayload], + 5: ["captured", list(capturedInput)], + 6: ["client", string], + 7: ["issuedAt", string], + 8: ["delegation", nullable(string)], + }, + named("CommandInvocation"), +); + +// result.rs +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"), +); +export const effect = required( + codecs.union( + "Effect", + { 0: ["OpenSurface", openSurfaceEffect], 1: ["OpenExternal", openExternalEffect] }, + named("Effect"), + ), +); +export const completedOutcome = codecs.slots( + { 0: ["sourceVersion", nullable(version)], 1: ["clearDrafts", boolean], 2: ["effect", nullable(effect)] }, + named("CompletedOutcome"), +); +export const deniedOutcome = codecs.slots( + { 0: ["reason", string], 1: ["node", nullable(string)] }, + named("DeniedOutcome"), +); +export const failedOutcome = codecs.slots({ 0: ["reason", string] }, named("FailedOutcome")); +export const outcome = required( + codecs.union( + "Outcome", + { 0: ["Pending", pendingOutcome], 1: ["Completed", completedOutcome], 2: ["Denied", deniedOutcome], 3: ["Failed", failedOutcome] }, + named("Outcome"), + ), +); + +// node.rs +export const nodeCommon = codecs.slots( + { 0: ["id", string], 1: ["visible", boolSource], 2: ["a11yLabel", nullable(textSource)] }, + named("NodeCommon"), +); + +// Node closes its own cycle here and nowhere else. +export const node: Codec = lazy(() => nodeUnion); + +export const boxNode = codecs.slots( + { 0: ["common", nodeCommon], 1: ["style", boxStyle], 2: ["paint", paint], 3: ["role", role], 4: ["children", list(node)] }, + named("BoxNode"), +); +export const textNode = codecs.slots( + { + 0: ["common", nodeCommon], + 1: ["content", textSource], + 2: ["styleToken", string], + 3: ["wrap", boolean], + 4: ["maxLines", nullable(count)], + 5: ["selectable", boolean], + }, + named("TextNode"), +); +export const imageFit = required( + codecs.union( + "ImageFit", + { 0: ["Contain", unit], 1: ["Cover", unit], 2: ["Fill", unit], 3: ["Unscaled", unit] }, + named("ImageFit"), + ), +); +export const imageNode = codecs.slots( + { 0: ["common", nodeCommon], 1: ["catalog", string], 2: ["asset", string], 3: ["fit", imageFit], 4: ["alt", textSource] }, + named("ImageNode"), +); +export const buttonNode = codecs.slots( + { 0: ["common", nodeCommon], 1: ["content", list(node)], 2: ["onActivate", commandRef], 3: ["enabled", boolSource] }, + named("ButtonNode"), +); +export const toggleNode = codecs.slots( + { 0: ["common", nodeCommon], 1: ["checked", boolSource], 2: ["onChange", commandRef] }, + named("ToggleNode"), +); +export const sliderNode = codecs.slots( + { + 0: ["common", nodeCommon], + 1: ["min", float64], + 2: ["max", float64], + 3: ["step", float64], + 4: ["value", numberSource], + 5: ["commit", commandRef], + }, + named("SliderNode"), +); +export const inputKind = required( + codecs.union("InputKind", { 0: ["Text", unit], 1: ["Number", unit] }, named("InputKind")), +); +export const textInputNode = codecs.slots( + { + 0: ["common", nodeCommon], + 1: ["multiline", boolean], + 2: ["inputKind", inputKind], + 3: ["placeholder", textSource], + 4: ["initial", textSource], + 5: ["submit", commandRef], + }, + named("TextInputNode"), +); +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")), +); +export const selectNode = codecs.slots( + { 0: ["common", nodeCommon], 1: ["options", optionList], 2: ["value", bind], 3: ["onChange", commandRef] }, + named("SelectNode"), +); +export const repeatNode = codecs.slots( + { 0: ["common", nodeCommon], 1: ["collection", collectionSource], 2: ["keyMember", memberPath], 3: ["template", node] }, + named("RepeatNode"), +); +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")), +); +export const overlayNode = codecs.slots( + { + 0: ["common", nodeCommon], + 1: ["anchor", anchor], + 2: ["modal", boolean], + 3: ["dismiss", commandRef], + 4: ["children", list(node)], + }, + named("OverlayNode"), +); +export const plotMark = required( + codecs.union("PlotMark", { 0: ["Line", unit], 1: ["Bar", unit], 2: ["Area", unit] }, named("PlotMark")), +); +export const plotNode = codecs.slots( + { + 0: ["common", nodeCommon], + 1: ["series", bind], + 2: ["mark", plotMark], + 3: ["xMin", nullable(float64)], + 4: ["xMax", nullable(float64)], + 5: ["yMin", nullable(float64)], + 6: ["yMax", nullable(float64)], + }, + named("PlotNode"), +); +export const embedNode = codecs.slots( + { + 0: ["common", nodeCommon], + 1: ["slotKind", string], + 2: ["slotId", string], + 3: ["width", float64], + 4: ["height", float64], + }, + named("EmbedNode"), +); +export const compositionRef = codecs.slots( + { 0: ["publisher", string], 1: ["name", segment], 2: ["version", version] }, + named("CompositionRef"), +); +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", + { + 0: ["Text", textArg], + 1: ["Number", numberArg], + 2: ["Bool", boolArg], + 3: ["Bind", bind], + 4: ["Collection", collectionArg], + 5: ["Command", commandArg], + 6: ["Slot", slotArg], + }, + named("ArgValue"), + ), +); +export const arg = codecs.slots({ 0: ["name", string], 1: ["value", argValue] }, named("Arg")); +export const useNode = codecs.slots( + { 0: ["common", nodeCommon], 1: ["composition", compositionRef], 2: ["args", list(arg)] }, + named("UseNode"), +); + +const nodeUnion = required( + codecs.union( + "Node", + { + 0: ["Box", boxNode], + 1: ["Text", textNode], + 2: ["Image", imageNode], + 3: ["Button", buttonNode], + 4: ["Toggle", toggleNode], + 5: ["Slider", sliderNode], + 6: ["TextInput", textInputNode], + 7: ["Select", selectNode], + 8: ["Repeat", repeatNode], + 9: ["Overlay", overlayNode], + 10: ["Plot", plotNode], + 11: ["Embed", embedNode], + 12: ["Use", useNode], + }, + named("Node"), + ), +); + +export const paramKind = required( + codecs.union( + "ParamKind", + { + 0: ["Text", unit], + 1: ["Number", unit], + 2: ["Bool", unit], + 3: ["Bind", unit], + 4: ["Collection", unit], + 5: ["Command", unit], + 6: ["Slot", unit], + }, + named("ParamKind"), + ), +); +export const paramDecl = codecs.slots({ 0: ["name", string], 1: ["kind", paramKind] }, named("ParamDecl")); + +// The documents' slot records. CommandResult's is in result.ts: it is the one that mints an instance. +export const surfaceDocument = codecs.slots( + { + 0: ["providerId", string], + 1: ["label", segment], + 2: ["version", version], + 3: ["title", string], + 4: ["root", node], + 5: ["commands", list(commandDecl)], + }, + named("SurfaceDocument"), +); +export const compositionDocument = codecs.slots( + { + 0: ["publisher", string], + 1: ["name", segment], + 2: ["version", version], + 3: ["params", list(paramDecl)], + 4: ["body", node], + }, + named("CompositionDocument"), +); +export const assetEntry = codecs.slots( + { 0: ["id", string], 1: ["mediaType", string], 2: ["uri", string], 3: ["width", count], 4: ["height", count] }, + named("AssetEntry"), +); +export const assetCatalog = codecs.slots( + { 0: ["providerId", string], 1: ["catalogId", segment], 2: ["version", version], 3: ["assets", list(assetEntry)] }, + named("AssetCatalog"), +); diff --git a/packages/thing-contract/src/documents.ts b/packages/thing-contract/src/documents.ts new file mode 100644 index 0000000..1b5a994 --- /dev/null +++ b/packages/thing-contract/src/documents.ts @@ -0,0 +1,65 @@ +// The five document definitions (crates/thing-contract/src/lib.rs). `type` and `schemaVersion` are the crate's +// cultcache(type, schema). The codec runs on both doors (encodeDocumentPayload and decodeDocumentPayload run it and then +// the definition's schema.parse), so the domains hold whichever way a value enters. The four plain kinds parse as the +// identity; a command_result parses only an instance of CommandResult. +import { defineDocumentRegistry, defineDocumentType } from "@gamecult/cultcache-ts"; + +import { assetCatalog, commandInvocation, compositionDocument as compositionCodec, surfaceDocument as surfaceCodec } from "./codecs.js"; +import { CommandResult, commandResult as commandResultCodec } from "./result.js"; +import type { AssetCatalog, CommandInvocation, CompositionDocument, SurfaceDocument } from "./types.js"; + +const identity = (input: unknown): TValue => input as TValue; + +export const surfaceDocument = defineDocumentType({ + type: "gamecult.thing.surface", + schemaName: "gamecult.thing.surface", + schemaVersion: "gamecult.thing.surface.v1", + schema: { parse: identity }, + codec: surfaceCodec, +}); + +export const compositionDocument = defineDocumentType({ + type: "gamecult.thing.composition", + schemaName: "gamecult.thing.composition", + schemaVersion: "gamecult.thing.composition.v1", + schema: { parse: identity }, + codec: compositionCodec, +}); + +export const commandInvocationDocument = defineDocumentType({ + type: "gamecult.thing.command_invocation", + schemaName: "gamecult.thing.command_invocation", + schemaVersion: "gamecult.thing.command_invocation.v1", + schema: { parse: identity }, + codec: commandInvocation, +}); + +export const commandResultDocument = defineDocumentType({ + type: "gamecult.thing.command_result", + schemaName: "gamecult.thing.command_result", + schemaVersion: "gamecult.thing.command_result.v1", + schema: { + parse(input: unknown): CommandResult { + if (!CommandResult.isResult(input)) throw new Error("a CommandResult is built by its factories"); + return input; + }, + }, + codec: commandResultCodec, +}); + +export const assetCatalogDocument = defineDocumentType({ + type: "gamecult.thing.asset_catalog", + schemaName: "gamecult.thing.asset_catalog", + schemaVersion: "gamecult.thing.asset_catalog.v1", + schema: { parse: identity }, + codec: assetCatalog, +}); + +/** The five kinds, as the crate's ThingContract registers them. */ +export const thingContract = defineDocumentRegistry( + surfaceDocument, + compositionDocument, + commandInvocationDocument, + commandResultDocument, + assetCatalogDocument, +); diff --git a/packages/thing-contract/src/domain.ts b/packages/thing-contract/src/domain.ts new file mode 100644 index 0000000..ee53892 --- /dev/null +++ b/packages/thing-contract/src/domain.ts @@ -0,0 +1,51 @@ +// The value domains of the contract's numbers and local names (crates/thing-contract/src/domain.rs). Each is a leaf +// codec of cultcache-ts with the contract's bound added, so the bound holds on both doors of a document: encode and +// decode. A refusal names the domain and its bound, never the value (the leaf's own refusals do not echo one either). +import { codecs } from "@gamecult/cultcache-ts"; +import type { Codec } from "@gamecult/cultcache-ts"; + +// A domain's wire form is its value, so its encode hands the checked value back: a caller that holds a number from +// outside (a factory argument, a key part) passes it through `encode` and keeps what comes out. +export interface Domain extends Codec { + encode(value: TValue): TValue; +} + +function bounded( + leaf: Codec, + refusal: (value: TValue) => string | null, +): Domain { + const hold = (value: TValue): TValue => { + const refused = refusal(value); + if (refused !== null) throw new Error(refused); + return value; + }; + return { + typeName: leaf.typeName, + valueType: leaf.valueType, + encode: (value) => hold(leaf.encode(value) as TValue), + decode: (raw) => hold(leaf.decode(raw)), + }; +} + +/** A result's position in its invocation's answers, counting from 1. */ +export const seq = bounded(codecs.int32, (value) => (value >= 1 ? null : "Seq must be at least 1")); + +/** A document or source version, never negative. */ +export const version = bounded(codecs.int64, (value) => (value >= 0 ? null : "Version must be at least 0")); + +/** A count, an extent or an index, never negative. */ +export const count = bounded(codecs.int32, (value) => (value >= 0 ? null : "Count must be at least 0")); + +/** + * A name the contract joins into a record key: non-empty, no '.'. A domain admits no nil, and the library refuses a nil + * in a value slot, so it is declared one. + */ +export const segment: Domain = { + ...bounded(codecs.string, (value) => + value === "" ? "Segment must be non-empty" : value.includes(".") ? "Segment must be free of '.'" : null, + ), + valueType: true, +}; + +/** One colour channel: a byte. */ +export const channel = bounded(codecs.int32, (value) => (value >= 0 && value <= 255 ? null : "Rgba channel must be 0 to 255")); diff --git a/packages/thing-contract/src/index.ts b/packages/thing-contract/src/index.ts new file mode 100644 index 0000000..5b91d18 --- /dev/null +++ b/packages/thing-contract/src/index.ts @@ -0,0 +1,6 @@ +export * as codecs from "./codecs.js"; +export * as domain from "./domain.js"; +export * from "./documents.js"; +export * from "./keys.js"; +export { CommandResult } from "./result.js"; +export type * from "./types.js"; diff --git a/packages/thing-contract/src/keys.ts b/packages/thing-contract/src/keys.ts new file mode 100644 index 0000000..ca40192 --- /dev/null +++ b/packages/thing-contract/src/keys.ts @@ -0,0 +1,25 @@ +// The record key of each kind (crates/thing-contract/src/keys.rs). Provider, publisher and invocation ids are free +// strings (dotted, as the Verse names them); the trailing parts are typed, 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. Each local +// segment goes through the segment codec, and a seq or version through its own. +import { segment, seq, version } from "./domain.js"; + +export function surfaceKey(providerId: string, label: string): string { + return `thing:surface:${providerId}.${segment.encode(label)}`; +} + +export function compositionKey(publisher: string, name: string, versionNumber: number | bigint): string { + return `thing:composition:${publisher}.${segment.encode(name)}.v${version.encode(versionNumber)}`; +} + +export function invocationKey(invocationId: string): string { + return `thing:invocation:${invocationId}`; +} + +export function resultKey(invocationId: string, position: number): string { + return `thing:result:${invocationId}.${seq.encode(position)}`; +} + +export function assetCatalogKey(providerId: string, catalogId: string): string { + return `thing:asset_catalog:${providerId}.${segment.encode(catalogId)}`; +} diff --git a/packages/thing-contract/src/result.ts b/packages/thing-contract/src/result.ts new file mode 100644 index 0000000..b87ae6b --- /dev/null +++ b/packages/thing-contract/src/result.ts @@ -0,0 +1,86 @@ +// The answer to a command invocation. A CommandResult is built by its four factories, each of which copies the +// invocation's id, and by the command_result document codec when it decodes. Decoding is the other door and holds the +// same domain: `seq` is refused below 1 whichever door it enters by. No export takes a state as a string or converts a +// plain object: an object literal, a spread, structuredClone copy or parsed JSON is not a result, and encoding one is +// refused (schema.parse in documents.ts). +// +// The copied invocation id is a convenience and not provenance: core binds a result to an invocation it issued. +import { codecs } from "@gamecult/cultcache-ts"; +import type { SlotsCodec } from "@gamecult/cultcache-ts"; + +import { outcome } from "./codecs.js"; +import { seq } from "./domain.js"; +import type { CommandInvocation, Effect, NodeId, Outcome } from "./types.js"; + +// The one constructor call. The class's static block sets `mint`; the factories and the codec below use it; it is not +// exported. The constructor refuses a call that does not carry `door`, so a script that reaches the constructor at run +// time (the TypeScript `private` is compile-time only) builds nothing, and it holds the seq domain for every door. +const door: unique symbol = Symbol("CommandResult.door"); +let mint: (invocationId: string, position: number, outcome: Outcome) => CommandResult; + +export class CommandResult { + readonly #brand = true; + readonly invocationId: string; + readonly seq: number; + readonly outcome: Outcome; + + private constructor(key: typeof door, invocationId: string, position: number, outcome: Outcome) { + if (key !== door) throw new Error("a CommandResult is built by its factories"); + this.invocationId = invocationId; + this.seq = seq.encode(position); + this.outcome = outcome; + Object.freeze(this); + } + + static { + mint = (invocationId, position, outcome) => new CommandResult(door, invocationId, position, outcome); + } + + static pending(invocation: CommandInvocation, position: number, message: string): CommandResult { + return mint(invocation.invocationId, position, { kind: "Pending", message }); + } + + static completed( + invocation: CommandInvocation, + position: number, + sourceVersion: number | bigint | null, + clearDrafts: boolean, + effect: Effect | null, + ): CommandResult { + return mint(invocation.invocationId, position, { kind: "Completed", sourceVersion, clearDrafts, effect }); + } + + static denied(invocation: CommandInvocation, position: number, reason: string, node: NodeId | null): CommandResult { + return mint(invocation.invocationId, position, { kind: "Denied", reason, node }); + } + + static failed(invocation: CommandInvocation, position: number, reason: string): CommandResult { + return mint(invocation.invocationId, position, { kind: "Failed", reason }); + } + + /** True for an instance a factory or the codec made; false for every other value, whatever its shape or prototype. */ + static isResult(value: unknown): value is CommandResult { + return typeof value === "object" && value !== null && #brand in value; + } +} + +// The command_result document codec: the slot record, with decode minting an instance. Encode reads the instance's +// fields through the same slot record, so the seq domain is held on both doors. +const slotRecord = codecs.slots<{ invocationId: string; seq: number; outcome: Outcome }>( + { 0: ["invocationId", codecs.string], 1: ["seq", seq], 2: ["outcome", outcome] }, + "GameCult.Thing.Contract.CommandResult", +); + +export const commandResult: SlotsCodec = { + names: slotRecord.names, + typeName: slotRecord.typeName, + get members() { + return slotRecord.members; + }, + encode: (value) => slotRecord.encode(value), + decode: (raw) => { + const { invocationId, seq: position, outcome: answer } = slotRecord.decode(raw); + return mint(invocationId, position, answer); + }, +}; + diff --git a/packages/thing-contract/src/types.ts b/packages/thing-contract/src/types.ts new file mode 100644 index 0000000..50249d0 --- /dev/null +++ b/packages/thing-contract/src/types.ts @@ -0,0 +1,369 @@ +// One TypeScript type per type of crates/thing-contract. A record is an interface of readonly camelCase fields; a closed +// set is a union discriminated on `kind`, the Rust arm name, and an arm carries its payload's fields beside the tag (a +// unit arm is just the tag). Option is `T | null`, Vec is `readonly T[]`, bytes are a Uint8Array. +// +// The value domains (Seq, Version, Count, Segment, an Rgba channel) are held by the codecs in domain.ts, not by these +// types: a type cannot say "at least 1". Every document is minted and checked by its codec. + +type Arm = { readonly kind: Kind } & Payload; + +export type ProviderId = string; +export type SchemaId = string; +export type RecordKey = string; +export type NodeId = string; +export type CommandId = string; + +// source.rs +export interface ParamName { readonly name: string } +export interface TextLiteral { readonly value: string } +export interface NumberLiteral { readonly value: number } +export interface BoolLiteral { readonly value: boolean } +export type TextSource = Arm<"Literal", TextLiteral> | Arm<"Bound", Bind> | Arm<"Param", ParamName>; +export type NumberSource = Arm<"Literal", NumberLiteral> | Arm<"Bound", Bind> | Arm<"Param", ParamName>; +export type BoolSource = Arm<"Literal", BoolLiteral> | Arm<"Bound", Bind> | Arm<"Param", ParamName>; +export interface Bind { + readonly source: StateSource; + readonly member: MemberPath; + readonly format: Format | null; +} +export type StateSource = Arm<"Record", StateRef> | Arm<"Item">; +export interface StateRef { + readonly provider: ProviderId | null; + readonly schema: SchemaId; + readonly key: RecordKey; +} +export interface MemberPath { readonly steps: readonly MemberStep[] } +export type MemberStep = Arm<"Name", MemberName> | Arm<"Index", MemberIndex>; +export interface MemberName { readonly name: string } +/** `index` is a Count: at least 0. */ +export interface MemberIndex { readonly index: number } +export type Format = Arm<"Number", NumberFormat> | Arm<"Duration"> | Arm<"Timestamp"> | Arm<"Affix", AffixFormat>; +/** `precision` is a Count: at least 0. */ +export interface NumberFormat { + readonly precision: number; + readonly percent: boolean; + readonly unit: string | null; +} +export interface AffixFormat { readonly prefix: string; readonly suffix: string } +export type CollectionSource = Arm<"Records", RecordsSource> | Arm<"Member", Bind> | Arm<"Param", ParamName>; +export interface RecordsSource { + readonly provider: ProviderId | null; + readonly schema: SchemaId; + readonly keyPrefix: string; +} + +// style.rs +export interface Scalar { readonly value: number } +export type Dimension = + | Arm<"Px", Scalar> + | Arm<"Percent", Scalar> + | Arm<"Fr", Scalar> + | Arm<"Auto"> + | Arm<"Content">; +export interface Size2 { readonly width: Dimension; readonly height: Dimension } +export interface Edges { + readonly top: Dimension; + readonly right: Dimension; + readonly bottom: Dimension; + readonly left: Dimension; +} +export type Display = Arm<"Flex"> | Arm<"Grid"> | Arm<"Block">; +export type Direction = Arm<"Row"> | Arm<"Column"> | Arm<"RowReverse"> | Arm<"ColumnReverse">; +export type Wrap = Arm<"NoWrap"> | Arm<"Wrap"> | Arm<"WrapReverse">; +export type Position = Arm<"Relative"> | Arm<"Absolute">; +export type Overflow = Arm<"Visible"> | Arm<"Clip"> | Arm<"Scroll">; +export type ItemAlign = Arm<"Auto"> | Arm<"Start"> | Arm<"End"> | Arm<"Center"> | Arm<"Stretch"> | Arm<"Baseline">; +export type ContentAlign = + | Arm<"Auto"> + | Arm<"Start"> + | Arm<"End"> + | Arm<"Center"> + | Arm<"Stretch"> + | Arm<"SpaceBetween"> + | Arm<"SpaceAround"> + | Arm<"SpaceEvenly">; +export interface BoxStyle { + readonly display: Display; + readonly direction: Direction; + readonly wrap: Wrap; + readonly gap: Size2; + readonly padding: Edges; + readonly margin: Edges; + readonly size: Size2; + readonly minSize: Size2; + readonly maxSize: Size2; + readonly position: Position; + readonly inset: Edges; + readonly overflow: Overflow; + readonly alignItems: ItemAlign; + readonly alignSelf: ItemAlign; + readonly alignContent: ContentAlign; + readonly justifyContent: ContentAlign; + readonly grow: number; + readonly shrink: number; + readonly basis: Dimension; + readonly gridColumns: readonly Dimension[]; + readonly gridRows: readonly Dimension[]; +} +export interface ThemeToken { readonly name: string } +/** Each channel is a byte: 0 to 255. */ +export interface Rgba { readonly r: number; readonly g: number; readonly b: number; readonly a: number } +export type ColorRef = Arm<"Token", ThemeToken> | Arm<"Rgba", Rgba>; +export interface Border { readonly color: ColorRef; readonly width: number } +export interface Paint { + readonly background: ColorRef | null; + readonly border: Border | null; + readonly radius: number; +} +export type Role = + | Arm<"Group"> + | Arm<"Region"> + | Arm<"List"> + | Arm<"ListItem"> + | Arm<"Heading"> + | Arm<"Dialog"> + | Arm<"Toolbar">; + +// command.rs +export interface CommandDecl { + readonly id: CommandId; + readonly payloadSchema: SchemaId; + readonly captures: readonly NodeId[]; + readonly confirm: string | null; +} +export interface CommandIdRef { readonly id: CommandId } +export type CommandRef = Arm<"Id", CommandIdRef> | Arm<"Param", ParamName>; +export interface RecordPayload { readonly schema: SchemaId; readonly bytes: Uint8Array } +export interface TextValue { readonly value: string } +export interface NumberValue { readonly value: number } +export interface BoolValue { readonly value: boolean } +export interface ChoiceValue { readonly value: string } +export type InputValue = Arm<"Text", TextValue> | Arm<"Number", NumberValue> | Arm<"Bool", BoolValue> | Arm<"Choice", ChoiceValue>; +export interface CapturedInput { readonly node: NodeId; readonly value: InputValue } +/** + * A consumer's request that a provider run a declared command. Its fields are plain, so a result's copy of + * `invocationId` is a convenience and not provenance: core binds results to the invocations it issued. + * `surfaceVersion` is a Version: at least 0. + */ +export interface CommandInvocation { + readonly invocationId: string; + readonly surfaceKey: string; + readonly surfaceVersion: number | bigint; + readonly command: CommandId; + readonly payload: RecordPayload; + readonly captured: readonly CapturedInput[]; + readonly client: string; + readonly issuedAt: string; + readonly delegation: string | null; +} + +// result.rs +export interface PendingOutcome { readonly message: string } +/** `sourceVersion` is a Version: at least 0. */ +export interface CompletedOutcome { + readonly sourceVersion: number | bigint | null; + readonly clearDrafts: boolean; + readonly effect: Effect | null; +} +export interface DeniedOutcome { readonly reason: string; readonly node: NodeId | null } +export interface FailedOutcome { readonly reason: string } +export type Outcome = + | Arm<"Pending", PendingOutcome> + | Arm<"Completed", CompletedOutcome> + | Arm<"Denied", DeniedOutcome> + | Arm<"Failed", FailedOutcome>; +export interface OpenSurfaceEffect { readonly key: string } +export interface OpenExternalEffect { readonly url: string; readonly resume: CommandRef } +export type Effect = Arm<"OpenSurface", OpenSurfaceEffect> | Arm<"OpenExternal", OpenExternalEffect>; + +/** A provider's handler for one invocation: the outcome it answers with. */ +export type Handler = (invocation: CommandInvocation) => Outcome | Promise; + +// node.rs +export interface NodeCommon { + readonly id: NodeId; + readonly visible: BoolSource; + readonly a11yLabel: TextSource | null; +} +export type Node = + | Arm<"Box", BoxNode> + | Arm<"Text", TextNode> + | Arm<"Image", ImageNode> + | Arm<"Button", ButtonNode> + | Arm<"Toggle", ToggleNode> + | Arm<"Slider", SliderNode> + | Arm<"TextInput", TextInputNode> + | Arm<"Select", SelectNode> + | Arm<"Repeat", RepeatNode> + | Arm<"Overlay", OverlayNode> + | Arm<"Plot", PlotNode> + | Arm<"Embed", EmbedNode> + | Arm<"Use", UseNode>; +export interface BoxNode { + readonly common: NodeCommon; + readonly style: BoxStyle; + readonly paint: Paint; + readonly role: Role; + readonly children: readonly Node[]; +} +/** `maxLines` is a Count: at least 0. */ +export interface TextNode { + readonly common: NodeCommon; + readonly content: TextSource; + readonly styleToken: string; + readonly wrap: boolean; + readonly maxLines: number | null; + readonly selectable: boolean; +} +export type ImageFit = Arm<"Contain"> | Arm<"Cover"> | Arm<"Fill"> | Arm<"Unscaled">; +export interface ImageNode { + readonly common: NodeCommon; + readonly catalog: string; + readonly asset: string; + readonly fit: ImageFit; + readonly alt: TextSource; +} +export interface ButtonNode { + readonly common: NodeCommon; + readonly content: readonly Node[]; + readonly onActivate: CommandRef; + readonly enabled: BoolSource; +} +export interface ToggleNode { + readonly common: NodeCommon; + readonly checked: BoolSource; + readonly onChange: CommandRef; +} +export interface SliderNode { + readonly common: NodeCommon; + readonly min: number; + readonly max: number; + readonly step: number; + readonly value: NumberSource; + readonly commit: CommandRef; +} +export type InputKind = Arm<"Text"> | Arm<"Number">; +/** + * The Rust field is `kind`; here it is `inputKind`, because a TextInput arm carries its payload beside the union's own + * `kind` tag and the two cannot share a name. + */ +export interface TextInputNode { + readonly common: NodeCommon; + readonly multiline: boolean; + readonly inputKind: InputKind; + readonly placeholder: TextSource; + readonly initial: TextSource; + readonly submit: CommandRef; +} +export interface SelectOption { readonly value: string; readonly label: string } +export interface LiteralOptions { readonly options: readonly SelectOption[] } +export interface BoundOptions { + readonly collection: CollectionSource; + readonly valueMember: MemberPath; + readonly labelMember: MemberPath; +} +export type OptionList = Arm<"Literal", LiteralOptions> | Arm<"Bound", BoundOptions>; +export interface SelectNode { + readonly common: NodeCommon; + readonly options: OptionList; + readonly value: Bind; + readonly onChange: CommandRef; +} +export interface RepeatNode { + readonly common: NodeCommon; + readonly collection: CollectionSource; + readonly keyMember: MemberPath; + readonly template: Node; +} +export interface AnchorNode { readonly node: NodeId } +export type Anchor = Arm<"Viewport"> | Arm<"Node", AnchorNode>; +export interface OverlayNode { + readonly common: NodeCommon; + readonly anchor: Anchor; + readonly modal: boolean; + readonly dismiss: CommandRef; + readonly children: readonly Node[]; +} +export type PlotMark = Arm<"Line"> | Arm<"Bar"> | Arm<"Area">; +export interface PlotNode { + readonly common: NodeCommon; + readonly series: Bind; + readonly mark: PlotMark; + readonly xMin: number | null; + readonly xMax: number | null; + readonly yMin: number | null; + readonly yMax: number | null; +} +export interface EmbedNode { + readonly common: NodeCommon; + readonly slotKind: string; + readonly slotId: string; + readonly width: number; + readonly height: number; +} +/** `name` is a Segment (non-empty, no '.'); `version` is a Version (at least 0). */ +export interface CompositionRef { + readonly publisher: string; + readonly name: string; + readonly version: number | bigint; +} +export interface UseNode { + readonly common: NodeCommon; + readonly composition: CompositionRef; + readonly args: readonly Arg[]; +} +export interface Arg { readonly name: string; readonly value: ArgValue } +export interface TextArg { readonly value: string } +export interface NumberArg { readonly value: number } +export interface BoolArg { readonly value: boolean } +export interface CollectionArg { readonly source: CollectionSource } +export interface CommandArg { readonly command: CommandRef } +export interface SlotArg { readonly nodes: readonly Node[] } +export type ArgValue = + | Arm<"Text", TextArg> + | Arm<"Number", NumberArg> + | Arm<"Bool", BoolArg> + | Arm<"Bind", Bind> + | Arm<"Collection", CollectionArg> + | Arm<"Command", CommandArg> + | Arm<"Slot", SlotArg>; +export type ParamKind = + | Arm<"Text"> + | Arm<"Number"> + | Arm<"Bool"> + | Arm<"Bind"> + | Arm<"Collection"> + | Arm<"Command"> + | Arm<"Slot">; +export interface ParamDecl { readonly name: string; readonly kind: ParamKind } + +// The documents. `label`, `name` and `catalogId` are Segments, `version` a Version, an extent a Count. The fifth +// document, CommandResult, is a class (result.ts). +export interface SurfaceDocument { + readonly providerId: ProviderId; + readonly label: string; + readonly version: number | bigint; + readonly title: string; + readonly root: Node; + readonly commands: readonly CommandDecl[]; +} +export interface CompositionDocument { + readonly publisher: string; + readonly name: string; + readonly version: number | bigint; + readonly params: readonly ParamDecl[]; + readonly body: Node; +} +export interface AssetEntry { + readonly id: string; + readonly mediaType: string; + readonly uri: string; + readonly width: number; + readonly height: number; +} +export interface AssetCatalog { + readonly providerId: ProviderId; + readonly catalogId: string; + readonly version: number | bigint; + readonly assets: readonly AssetEntry[]; +} diff --git a/packages/thing-contract/test/builders.ts b/packages/thing-contract/test/builders.ts new file mode 100644 index 0000000..2bd6e48 --- /dev/null +++ b/packages/thing-contract/test/builders.ts @@ -0,0 +1,478 @@ +// The values the goldens are built from, ported from crates/thing-contract/tests/common/mod.rs and goldens.rs. Every +// value is an object literal of the package's exported types; no bytes are spelled here. The golden files are read, never +// written: the Rust crate blesses them. +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { CommandResult } from "@gamecult/thing-contract"; +import type { + Arg, + AssetCatalog, + BoxNode, + Bind, + BoolSource, + CollectionSource, + CommandInvocation, + CommandRef, + CompositionDocument, + Dimension, + Edges, + Format, + MemberPath, + Node, + NodeCommon, + NumberSource, + Paint, + Size2, + SurfaceDocument, + TextSource, + BoxStyle, +} from "@gamecult/thing-contract"; + +export const CANARY = "CANARY-7f3a91c2"; +export const PINNED_CLOCK = "2026-01-01T00:00:00Z"; + +/** The goldens directory, found by walking up from this file (a mutation sandbox sits deeper in the tree). */ +export const goldensDir: string = (() => { + let here = dirname(fileURLToPath(import.meta.url)); + for (;;) { + const candidate = join(here, "crates", "thing-contract", "tests", "goldens"); + if (existsSync(candidate)) return candidate; + const parent = dirname(here); + if (parent === here) throw new Error("the Rust crate's goldens directory is not above this file"); + here = parent; + } +})(); + +export function golden(file: string): Uint8Array { + return new Uint8Array(readFileSync(join(goldensDir, file))); +} + +export const lit = (value: string): TextSource => ({ kind: "Literal", value }); +export const litBool = (value: boolean): BoolSource => ({ kind: "Literal", value }); +export const litNumber = (value: number): NumberSource => ({ kind: "Literal", value }); +export const param = (name: string) => ({ kind: "Param", name }) as const; +export const path = (...names: string[]): MemberPath => ({ steps: names.map((name) => ({ kind: "Name", name }) as const) }); +export const command = (id: string): CommandRef => ({ kind: "Id", id }); + +export function recordBind(member: MemberPath, format: Format | null): Bind { + return { + source: { kind: "Record", provider: "provider.a", schema: "gamecult.thing.test.v1", key: "row-1" }, + member, + format, + }; +} +export const itemBind = (member: MemberPath): Bind => ({ source: { kind: "Item" }, member, format: null }); + +export const common = (id: string): NodeCommon => ({ id, visible: litBool(true), a11yLabel: null }); +export const px = (value: number): Dimension => ({ kind: "Px", value }); +export const size2 = (width: Dimension, height: Dimension): Size2 => ({ width, height }); +export const edges = (all: Dimension): Edges => ({ top: all, right: all, bottom: all, left: all }); + +export function style(): BoxStyle { + return { + display: { kind: "Flex" }, + direction: { kind: "Column" }, + wrap: { kind: "NoWrap" }, + gap: size2(px(4), px(8)), + padding: edges(px(2)), + margin: edges({ kind: "Auto" }), + size: size2({ kind: "Percent", value: 100 }, { kind: "Content" }), + minSize: size2(px(0), px(0)), + maxSize: size2({ kind: "Auto" }, { kind: "Auto" }), + position: { kind: "Relative" }, + inset: edges({ kind: "Auto" }), + overflow: { kind: "Visible" }, + alignItems: { kind: "Stretch" }, + alignSelf: { kind: "Auto" }, + alignContent: { kind: "Start" }, + justifyContent: { kind: "SpaceBetween" }, + grow: 1, + shrink: 0, + basis: { kind: "Auto" }, + gridColumns: [], + gridRows: [], + }; +} + +export const plainPaint = (): Paint => ({ background: null, border: null, radius: 0 }); + +export function boxNode(id: string, children: readonly Node[]): BoxNode { + return { common: common(id), style: style(), paint: plainPaint(), role: { kind: "Group" }, children }; +} +export const boxed = (id: string, children: readonly Node[]): Node => ({ kind: "Box", ...boxNode(id, children) }); +export const text = (id: string, content: string): Node => ({ + kind: "Text", + common: common(id), + content: lit(content), + styleToken: "body", + wrap: true, + maxLines: null, + selectable: false, +}); + +export function invocation(): CommandInvocation { + return { + invocationId: "3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b", + surfaceKey: "thing:surface:provider.a.main", + surfaceVersion: 7, + command: "go", + payload: { schema: "gamecult.thing.test.go.v1", bytes: new Uint8Array([0x91, 0x01, 0xff, 0x00]) }, + captured: [ + { node: "name-input", value: { kind: "Text", value: "Ada" } }, + { node: "level", value: { kind: "Number", value: 0.75 } }, + { node: "flag", value: { kind: "Bool", value: true } }, + { node: "pick", value: { kind: "Choice", value: "a" } }, + ], + client: "renderer.web", + issuedAt: PINNED_CLOCK, + delegation: "delegate-1", + }; +} + +export function bindAllFormats(): Bind[] { + const number: Format = { kind: "Number", precision: 2, percent: true, unit: "ms" }; + const affix: Format = { kind: "Affix", prefix: "$", suffix: "/s" }; + const indexed: MemberPath = { steps: [{ kind: "Name", name: "items" }, { kind: "Index", index: 3 }] }; + return [ + recordBind(indexed, number), + recordBind(path("elapsed"), { kind: "Duration" }), + recordBind(path("at"), { kind: "Timestamp" }), + recordBind(path("price"), affix), + recordBind(path("plain"), null), + itemBind(path("name")), + ]; +} + +export function fullSurface(): SurfaceDocument { + const binds = bindAllFormats(); + const boundText: TextSource = { kind: "Bound", ...binds[3] }; + const boundBool: BoolSource = { kind: "Bound", ...binds[4] }; + const boundNumber: NumberSource = { kind: "Bound", ...binds[0] }; + + const painted: Node = { + kind: "Box", + common: { id: "painted", visible: param("show"), a11yLabel: param("label") }, + style: { + ...style(), + display: { kind: "Grid" }, + gridColumns: [{ kind: "Fr", value: 1 }, px(120)], + gridRows: [{ kind: "Content" }], + position: { kind: "Absolute" }, + overflow: { kind: "Scroll" }, + direction: { kind: "RowReverse" }, + wrap: { kind: "WrapReverse" }, + alignItems: { kind: "Baseline" }, + alignSelf: { kind: "Center" }, + alignContent: { kind: "SpaceAround" }, + justifyContent: { kind: "SpaceEvenly" }, + }, + paint: { + background: { kind: "Token", name: "surface.raised" }, + border: { color: { kind: "Rgba", r: 1, g: 2, b: 3, a: 255 }, width: 1.5 }, + radius: 4, + }, + role: { kind: "Region" }, + children: [text("deep-text", "deep")], + }; + + const repeat: Node = { + kind: "Repeat", + common: common("rows"), + collection: { kind: "Records", provider: null, schema: "gamecult.thing.test.v1", keyPrefix: "row-" }, + keyMember: path("id"), + template: { + kind: "Text", + common: common("row-name"), + content: { kind: "Bound", ...itemBind(path("name")) }, + styleToken: "body", + wrap: false, + maxLines: 2, + selectable: true, + }, + }; + + const leaves: Node[] = [ + { + kind: "Text", + common: common("t-bound"), + content: boundText, + styleToken: "label", + wrap: true, + maxLines: null, + selectable: false, + }, + { kind: "Image", common: common("img"), catalog: "icons", asset: "gear", fit: { kind: "Cover" }, alt: lit("a gear") }, + { + kind: "Button", + common: common("go"), + content: [text("go-label", "Go")], + onActivate: command("go"), + enabled: boundBool, + }, + { kind: "Toggle", common: common("flag"), checked: boundBool, onChange: param("toggle") }, + { + kind: "Slider", + common: common("level"), + min: 0, + max: 1, + step: 0.25, + value: boundNumber, + commit: command("set-level"), + }, + { + kind: "TextInput", + common: common("name-input"), + multiline: true, + inputKind: { kind: "Number" }, + placeholder: lit("name"), + initial: param("initial"), + submit: command("rename"), + }, + { + kind: "TextInput", + common: common("title-input"), + multiline: false, + inputKind: { kind: "Text" }, + placeholder: lit("title"), + initial: param("initial"), + submit: command("retitle"), + }, + { + kind: "Select", + common: common("pick"), + options: { kind: "Literal", options: [{ value: "a", label: "Alpha" }] }, + value: binds[4], + onChange: command("pick"), + }, + { + kind: "Select", + common: common("pick-bound"), + options: { + kind: "Bound", + collection: { kind: "Member", ...binds[0] }, + valueMember: path("id"), + labelMember: path("name"), + }, + value: binds[5], + onChange: command("pick"), + }, + repeat, + { + kind: "Overlay", + common: common("menu"), + anchor: { kind: "Node", node: "go" }, + modal: false, + dismiss: command("close"), + children: [text("menu-item", "one")], + }, + { + kind: "Overlay", + common: common("dialog"), + anchor: { kind: "Viewport" }, + modal: true, + dismiss: command("close"), + children: [], + }, + { + kind: "Plot", + common: common("trend"), + series: binds[1], + mark: { kind: "Area" }, + xMin: 0, + xMax: null, + yMin: null, + yMax: 100, + }, + { kind: "Embed", common: common("viewport"), slotKind: "world.scene", slotId: "main", width: 640, height: 480 }, + { + kind: "Use", + common: common("kv"), + composition: { publisher: "thing.std", name: "key-value", version: 1 }, + args: [{ name: "key", value: { kind: "Text", value: "k" } }], + }, + ]; + + let deep: Node = boxed("d4", [painted]); + for (const id of ["d3", "d2", "d1"]) deep = boxed(id, [deep]); + const children: Node[] = [deep, ...leaves]; + for (const [id, mark] of [ + ["trend-line", { kind: "Line" }], + ["trend-bar", { kind: "Bar" }], + ] as const) { + children.push({ + kind: "Plot", + common: common(id), + series: binds[2], + mark, + xMin: null, + xMax: null, + yMin: null, + yMax: null, + }); + } + (["Contain", "Fill", "Unscaled"] as const).forEach((fit, index) => { + children.push({ + kind: "Image", + common: common(`img-fit-${index}`), + catalog: "icons", + asset: "gear", + fit: { kind: fit }, + alt: lit(""), + }); + }); + (["List", "ListItem", "Heading", "Dialog", "Toolbar"] as const).forEach((role, index) => { + const roleStyle: BoxStyle = + index === 0 + ? { ...style(), direction: { kind: "Row" }, alignContent: { kind: "Auto" }, justifyContent: { kind: "Stretch" } } + : style(); + children.push({ + kind: "Box", + common: common(`role-${index}`), + style: roleStyle, + paint: plainPaint(), + role: { kind: role }, + children: [], + }); + }); + children.push({ + kind: "Box", + common: common("block"), + style: { + ...style(), + display: { kind: "Block" }, + direction: { kind: "ColumnReverse" }, + wrap: { kind: "Wrap" }, + overflow: { kind: "Clip" }, + alignContent: { kind: "Center" }, + justifyContent: { kind: "End" }, + alignItems: { kind: "End" }, + alignSelf: { kind: "Start" }, + basis: { kind: "Content" }, + }, + paint: plainPaint(), + role: { kind: "Group" }, + children: [], + }); + children.push({ + kind: "Text", + common: common("t-param"), + content: param("title"), + styleToken: "title", + wrap: true, + maxLines: null, + selectable: false, + }); + children.push({ + kind: "Toggle", + common: { id: "literal-number", visible: param("v"), a11yLabel: null }, + checked: litBool(false), + onChange: command("toggle"), + }); + children.push({ + kind: "Slider", + common: common("slider-literal"), + min: -1, + max: 1, + step: 0.5, + value: litNumber(0.5), + commit: command("set-level"), + }); + children.push({ + kind: "Slider", + common: common("slider-param"), + min: 0, + max: 10, + step: 1, + value: param("n"), + commit: command("set-level"), + }); + const rows: CollectionSource = param("rows"); + children.push({ + kind: "Repeat", + common: common("rows-param"), + collection: rows, + keyMember: path("id"), + template: text("row-param", "r"), + }); + + return { + providerId: "provider.a", + label: "main", + version: 7, + title: "All arms", + root: boxed("root", children), + commands: [ + { id: "go", payloadSchema: "gamecult.thing.test.go.v1", captures: ["name-input", "level"], confirm: "Go now?" }, + { id: "close", payloadSchema: "gamecult.thing.test.close.v1", captures: [], confirm: null }, + ], + }; +} + +export function fullComposition(): CompositionDocument { + const args: Arg[] = [ + { name: "text", value: { kind: "Text", value: "t" } }, + { name: "number", value: { kind: "Number", value: 2.5 } }, + { name: "bool", value: { kind: "Bool", value: true } }, + { name: "bind", value: { kind: "Bind", ...recordBind(path("x"), null) } }, + { name: "collection", value: { kind: "Collection", source: param("rows") } }, + { name: "command", value: { kind: "Command", command: command("go") } }, + { name: "slot", value: { kind: "Slot", nodes: [text("slot-text", "inside"), boxed("slot-box", [])] } }, + ]; + const innerUse: Node = { + kind: "Use", + common: common("inner"), + composition: { publisher: "thing.std", name: "card", version: 2 }, + args, + }; + return { + publisher: "provider.a", + name: "panel", + version: 3, + params: [ + { name: "title", kind: { kind: "Text" } }, + { name: "count", kind: { kind: "Number" } }, + { name: "on", kind: { kind: "Bool" } }, + { name: "source", kind: { kind: "Bind" } }, + { name: "rows", kind: { kind: "Collection" } }, + { name: "act", kind: { kind: "Command" } }, + { name: "body", kind: { kind: "Slot" } }, + ], + body: boxed("panel-root", [text("panel-title", "t"), innerUse]), + }; +} + +export function results(answered: CommandInvocation): [string, CommandResult][] { + return [ + ["result-pending", CommandResult.pending(answered, 1, "working")], + ["result-completed", CommandResult.completed(answered, 2, 8, true, null)], + [ + "result-completed-open-surface", + CommandResult.completed(answered, 3, null, false, { kind: "OpenSurface", key: "thing:surface:provider.a.next" }), + ], + [ + "result-completed-open-external", + CommandResult.completed(answered, 4, 9, true, { + kind: "OpenExternal", + url: "https://example.test/auth", + resume: command("resume"), + }), + ], + ["result-denied", CommandResult.denied(answered, 5, "not yours", "go")], + ["result-denied-no-node", CommandResult.denied(answered, 6, "not allowed", null)], + ["result-failed", CommandResult.failed(answered, 7, "broke")], + ]; +} + +export function assetCatalogValue(): AssetCatalog { + return { + providerId: "provider.a", + catalogId: "icons", + version: 2, + assets: [ + { id: "gear", mediaType: "image/png", uri: "assets/gear.png", width: 32, height: 32 }, + { id: "bolt", mediaType: "image/svg+xml", uri: "assets/bolt.svg", width: 0, height: 0 }, + ], + }; +} 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/deep.test.ts b/packages/thing-contract/test/deep.test.ts new file mode 100644 index 0000000..f6b8d34 --- /dev/null +++ b/packages/thing-contract/test/deep.test.ts @@ -0,0 +1,76 @@ +// The deepest tree the payload bound admits decodes and re-encodes on Node's default stack (no --stack-size), and one +// level deeper is refused with CultCachePayloadDepthError, never an abort. The Rust test of the same name needs a 16 MiB +// thread for this; here the stack holds, and the bound refuses first. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CultCachePayloadDepthError, + MAX_PAYLOAD_DEPTH, + decodeDocumentPayload, + documentPayloadBytes, + documentPayloadFromBytes, + encodeDocumentPayload, +} from "@gamecult/cultcache-ts"; +import { surfaceDocument } from "@gamecult/thing-contract"; +import type { Node, SurfaceDocument } from "@gamecult/thing-contract"; + +import { boxNode, boxed, text } from "./builders.js"; + +function chain(levels: number): SurfaceDocument { + let root: Node = text("leaf", "bottom"); + for (let depth = levels - 1; depth >= 0; depth--) root = boxed(`deep-${depth}`, [root]); + return { providerId: "provider.a", label: "deep", version: 1, title: "deep", root, commands: [] }; +} + +const admits = (levels: number): boolean => { + try { + encodeDocumentPayload(surfaceDocument, chain(levels)); + return true; + } catch (error) { + assert.ok(error instanceof CultCachePayloadDepthError, "the only refusal of a plain chain is the depth bound"); + assert.equal(error.limit, MAX_PAYLOAD_DEPTH); + return false; + } +}; + +test("the deepest Box chain the bound admits round-trips on the default stack, and one more is refused", () => { + let deepest = 1; + while (admits(deepest + 1)) deepest++; + assert.equal(deepest, 83, "the bound admits 83 Box levels above the text leaf, 84 nodes deep"); + assert.ok(!admits(deepest + 1)); + + const value = chain(deepest); + const bytes = documentPayloadBytes(encodeDocumentPayload(surfaceDocument, value)); + const decoded = decodeDocumentPayload(surfaceDocument, documentPayloadFromBytes(bytes)); + assert.deepStrictEqual(decoded, value); + assert.deepEqual(documentPayloadBytes(encodeDocumentPayload(surfaceDocument, decoded)), bytes); +}); + +test("a payload one container past the bound is refused with the depth error, before any codec reads it", () => { + const nested = (levels: number) => new Uint8Array([...new Array(levels).fill(0x91), 0xc0]); + assert.throws( + () => decodeDocumentPayload(surfaceDocument, documentPayloadFromBytes(nested(MAX_PAYLOAD_DEPTH + 1))), + (error: unknown) => error instanceof CultCachePayloadDepthError && error.depth === MAX_PAYLOAD_DEPTH + 1, + ); + // At the bound the walk admits it and the codec refuses the shape instead. + assert.throws( + () => decodeDocumentPayload(surfaceDocument, documentPayloadFromBytes(nested(MAX_PAYLOAD_DEPTH))), + (error: unknown) => error instanceof Error && !(error instanceof CultCachePayloadDepthError), + ); +}); + +test("a wide tree of a thousand nodes round-trips", () => { + const children: Node[] = Array.from({ length: 998 }, (_, index) => text(`t${index}`, "x")); + children.push(boxed("tail", [])); + const wide: SurfaceDocument = { + providerId: "provider.a", + label: "wide", + version: 1, + title: "wide", + root: { kind: "Box", ...boxNode("root", children) }, + commands: [], + }; + const bytes = documentPayloadBytes(encodeDocumentPayload(surfaceDocument, wide)); + assert.deepStrictEqual(decodeDocumentPayload(surfaceDocument, documentPayloadFromBytes(bytes)), wide); +}); diff --git a/packages/thing-contract/test/definitions.test.ts b/packages/thing-contract/test/definitions.test.ts new file mode 100644 index 0000000..2cc60e2 --- /dev/null +++ b/packages/thing-contract/test/definitions.test.ts @@ -0,0 +1,86 @@ +// The five definitions carry the crate's cultcache(type, schema) and, per document slot, the reference's member name +// and C# type name that a store's schema catalog is compared on. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + assetCatalogDocument, + commandInvocationDocument, + commandResultDocument, + compositionDocument, + surfaceDocument, + thingContract, +} from "@gamecult/thing-contract"; + +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, + "1:Label:" + STRING, + "2:Version:System.Int64", + "3:Title:" + STRING, + `4:Root:${NS}Node`, + `5:Commands:${list("CommandDecl")}`, + ]], + [compositionDocument, "gamecult.thing.composition", [ + "0:Publisher:" + STRING, + "1:Name:" + STRING, + "2:Version:System.Int64", + `3:Params:${list("ParamDecl")}`, + `4:Body:${NS}Node`, + ]], + [commandInvocationDocument, "gamecult.thing.command_invocation", [ + "0:InvocationId:" + STRING, + "1:SurfaceKey:" + STRING, + "2:SurfaceVersion:System.Int64", + "3:Command:" + STRING, + `4:Payload:${NS}RecordPayload`, + `5:Captured:${list("CapturedInput")}`, + "6:Client:" + STRING, + "7:IssuedAt:" + STRING, + "8:Delegation:" + STRING, + ]], + [commandResultDocument, "gamecult.thing.command_result", [ + "0:InvocationId:" + STRING, + "1:Seq:System.Int32", + `2:Outcome:${NS}Outcome`, + ]], + [assetCatalogDocument, "gamecult.thing.asset_catalog", [ + "0:ProviderId:" + STRING, + "1:CatalogId:" + STRING, + "2:Version:System.Int64", + `3:Assets:${list("AssetEntry")}`, + ]], +] as const; + +for (const [definition, type, members] of expected) { + test(`${type}: type, schema and catalog members`, () => { + 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, + ); + }); +} + +test("the registry lists the five definitions, in the crate's order", () => { + assert.deepEqual( + thingContract.definitions.map((definition) => definition.type), + expected.map(([, type]) => type), + ); + assert.equal(new Set(thingContract.definitions).size, 5); +}); diff --git a/packages/thing-contract/test/factories.test.ts b/packages/thing-contract/test/factories.test.ts new file mode 100644 index 0000000..bc2cac3 --- /dev/null +++ b/packages/thing-contract/test/factories.test.ts @@ -0,0 +1,141 @@ +// A CommandResult exists only by a factory or by the decode door. Construction is attacked the way an outside consumer +// would: an object literal, JSON.parse output, structuredClone, spread, prototype tricks, the constructor, a Proxy. Each is +// refused where a result is encoded, and none is a CommandResult. The type-level half is test/types.ts. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { decodeDocumentPayload, documentPayloadBytes, documentPayloadFromBytes, encodeDocumentPayload } from "@gamecult/cultcache-ts"; +import { CommandResult, commandResultDocument } from "@gamecult/thing-contract"; + +import { CANARY, golden, invocation, results } from "./builders.js"; + +const answered = invocation(); +const encodeResult = (value: unknown) => () => encodeDocumentPayload(commandResultDocument, value); +const BUILT = /a CommandResult is built by its factories/; + +test("a factory copies the invocation id and holds the seq", () => { + const result = CommandResult.failed(answered, 5, "broke"); + assert.equal(result.invocationId, answered.invocationId); + assert.equal(result.seq, 5); + assert.deepEqual(result.outcome, { kind: "Failed", reason: "broke" }); + assert.ok(result instanceof CommandResult); + assert.ok(CommandResult.isResult(result)); +}); + +test("each factory builds its outcome", () => { + assert.deepEqual(CommandResult.pending(answered, 1, "working").outcome, { kind: "Pending", message: "working" }); + assert.deepEqual(CommandResult.completed(answered, 2, 8, true, null).outcome, { + kind: "Completed", + sourceVersion: 8, + clearDrafts: true, + effect: null, + }); + assert.deepEqual(CommandResult.denied(answered, 3, "no", "go").outcome, { kind: "Denied", reason: "no", node: "go" }); +}); + +test("a factory refuses seq below 1 naming Seq, and not the value", () => { + for (const bad of [0, -1, -(2 ** 31)]) { + for (const build of [ + () => CommandResult.pending(answered, bad, "m"), + () => CommandResult.completed(answered, bad, null, false, null), + () => CommandResult.denied(answered, bad, "r", null), + () => CommandResult.failed(answered, bad, "r"), + ]) { + assert.throws(build, (error: Error) => /Seq must be at least 1/.test(error.message) && !error.message.includes(String(bad))); + } + } + assert.throws(() => CommandResult.failed(answered, 1.5, "r"), /int32/); + assert.throws(() => CommandResult.failed(answered, 2 ** 31, "r"), /int32/); + assert.equal(CommandResult.failed(answered, 2 ** 31 - 1, "r").seq, 2 ** 31 - 1); +}); + +test("the decode door yields a CommandResult instance, equal to the factory's", () => { + for (const [file, value] of results(answered)) { + const decoded = decodeDocumentPayload(commandResultDocument, documentPayloadFromBytes(golden(`${file}.bin`))); + assert.ok(decoded instanceof CommandResult, file); + assert.ok(CommandResult.isResult(decoded), file); + assert.deepStrictEqual(decoded, value, file); + assert.ok(Object.isFrozen(decoded), file); + } +}); + +test("encoding a result a factory built succeeds, and re-decodes", () => { + const result = CommandResult.pending(answered, 1, "working"); + const bytes = documentPayloadBytes(encodeDocumentPayload(commandResultDocument, result)); + assert.deepEqual(bytes, golden("result-pending.bin")); +}); + +test("a copy of a result is not a result: structuredClone, spread and JSON.parse output are refused at encode", () => { + const result = CommandResult.failed(answered, 7, "broke"); + for (const [name, copy] of [ + ["structuredClone", structuredClone(result)], + ["spread", { ...result }], + ["JSON.parse", JSON.parse(JSON.stringify(result)) as unknown], + ["Object.assign", Object.assign({}, result)], + ] as const) { + assert.ok(!CommandResult.isResult(copy), name); + assert.ok(!(copy instanceof CommandResult), name); + assert.throws(encodeResult(copy), BUILT, name); + } +}); + +test("an object literal is not a result", () => { + const literal = { invocationId: answered.invocationId, seq: 1, outcome: { kind: "Failed", reason: "broke" } }; + assert.ok(!CommandResult.isResult(literal)); + assert.throws(encodeResult(literal), BUILT); + for (const other of [null, undefined, 0, "", "completed", [], {}, () => 0]) { + assert.ok(!CommandResult.isResult(other)); + assert.throws(encodeResult(other), BUILT); + } +}); + +test("prototype tricks do not make a result", () => { + const result = CommandResult.failed(answered, 7, "broke"); + const viaCreate = Object.assign(Object.create(CommandResult.prototype) as object, result); + const viaSetPrototype = Object.setPrototypeOf({ ...result }, CommandResult.prototype) as object; + const viaProxy = new Proxy(result, {}); + class Impostor extends (CommandResult as unknown as new () => object) {} + for (const [name, fake] of [["Object.create", viaCreate], ["setPrototypeOf", viaSetPrototype], ["Proxy", viaProxy]] as const) { + assert.ok(!CommandResult.isResult(fake), name); + assert.throws(encodeResult(fake), BUILT, name); + } + assert.throws(() => new Impostor(), BUILT); +}); + +test("the constructor is closed at run time as well as in types", () => { + const construct = CommandResult as unknown as new (...args: unknown[]) => CommandResult; + const outcome = { kind: "Failed", reason: "broke" }; + assert.throws(() => new construct(answered.invocationId, 1, outcome), BUILT); + assert.throws(() => new construct(Symbol("door"), answered.invocationId, 1, outcome), BUILT); + assert.throws(() => new construct(undefined, answered.invocationId, 0, outcome), BUILT); + assert.throws(() => Reflect.construct(construct, [answered.invocationId, 0, outcome]), BUILT); + assert.throws(() => Reflect.construct(construct, [], CommandResult), BUILT); +}); + +test("a result is frozen: a script that ignores types cannot move its seq out of domain", () => { + const result = CommandResult.failed(answered, 7, "broke"); + assert.ok(Object.isFrozen(result)); + const writable = result as unknown as { seq: number; invocationId: string }; + assert.throws(() => { + writable.seq = 0; + }, TypeError); + assert.throws(() => { + writable.invocationId = "other"; + }, TypeError); + assert.equal(result.seq, 7); + assert.equal(result.invocationId, answered.invocationId); +}); + +test("an out-of-domain seq cannot cross the decode door, and no message echoes the payload", () => { + const message = (run: () => unknown) => { + try { + run(); + } catch (error) { + return (error as Error).message; + } + return assert.fail("expected a refusal"); + }; + const refused = message(() => decodeDocumentPayload(commandResultDocument, documentPayloadFromBytes(golden("result-seq-zero.bin")))); + assert.match(refused, /Seq must be at least 1/); + assert.ok(!refused.includes(CANARY)); +}); diff --git a/packages/thing-contract/test/goldens.test.ts b/packages/thing-contract/test/goldens.test.ts new file mode 100644 index 0000000..fe021d8 --- /dev/null +++ b/packages/thing-contract/test/goldens.test.ts @@ -0,0 +1,48 @@ +// The value goldens: each file is what the Rust crate writes for the builder's value. Encoding the ported builder gives +// the file byte for byte, decoding the file gives the value back, and re-encoding what was decoded gives the file again. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + decodeDocumentPayload, + documentPayloadBytes, + documentPayloadFromBytes, + encodeDocumentPayload, +} from "@gamecult/cultcache-ts"; +import { + assetCatalogDocument, + commandInvocationDocument, + commandResultDocument, + compositionDocument, + surfaceDocument, +} from "@gamecult/thing-contract"; + +import { assetCatalogValue, fullComposition, fullSurface, golden, invocation, results } from "./builders.js"; + +type Case = [file: string, definition: Parameters[0], value: unknown]; + +const answered = invocation(); +const cases: Case[] = [ + ["surface-all-arms.bin", surfaceDocument, fullSurface()], + ["composition-all-params.bin", compositionDocument, fullComposition()], + ["invocation-all-inputs.bin", commandInvocationDocument, answered], + ...results(answered).map(([name, value]): Case => [`${name}.bin`, commandResultDocument, value]), + ["asset-catalog.bin", assetCatalogDocument, assetCatalogValue()], +]; + +test("the case table names the eleven value goldens", () => { + assert.equal(cases.length, 11); + assert.equal(new Set(cases.map(([file]) => file)).size, 11); + assert.equal(cases.filter(([, definition]) => definition === commandResultDocument).length, 7); +}); + +for (const [file, definition, value] of cases) { + test(`${file}: the builder encodes to the golden, the golden decodes to the builder, and re-encodes identical`, () => { + const committed = golden(file); + assert.deepEqual(documentPayloadBytes(encodeDocumentPayload(definition, value)), committed); + + const decoded = decodeDocumentPayload(definition, documentPayloadFromBytes(committed)); + assert.deepStrictEqual(decoded, value); + assert.deepEqual(documentPayloadBytes(encodeDocumentPayload(definition, decoded)), committed); + }); +} diff --git a/packages/thing-contract/test/keys.test.ts b/packages/thing-contract/test/keys.test.ts new file mode 100644 index 0000000..c228a89 --- /dev/null +++ b/packages/thing-contract/test/keys.test.ts @@ -0,0 +1,67 @@ +// The key grammar of crates/thing-contract/src/keys.rs and the cases of tests/result.rs: keys are built from the fields, +// every local segment is a Segment, and two different field sets never build one key. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { assetCatalogKey, compositionKey, invocationKey, resultKey, surfaceKey } from "@gamecult/thing-contract"; + +import { CANARY } from "./builders.js"; + +const builders: [string, (segment: string) => string][] = [ + ["surfaceKey", (segment) => surfaceKey("p", segment)], + ["compositionKey name", (segment) => compositionKey("pub", segment, 1)], + ["assetCatalogKey", (segment) => assetCatalogKey("p", segment)], +]; + +for (const [name, build] of builders) { + test(`${name} refuses an empty, a dot and a dotted segment, naming Segment and not the value`, () => { + assert.throws(() => build(""), /Segment must be non-empty/); + assert.throws(() => build("."), /Segment must be free of '\.'/); + assert.throws(() => build("a.b"), /Segment must be free of '\.'/); + assert.throws( + () => build(`${CANARY}.x`), + (error: Error) => /Segment/.test(error.message) && !error.message.includes(CANARY), + ); + }); +} + +test("the keys are spelled from the fields", () => { + assert.equal(invocationKey("an-invocation-no-constructor-can-guess"), "thing:invocation:an-invocation-no-constructor-can-guess"); + assert.equal(resultKey("an-invocation-no-constructor-can-guess", 3), "thing:result:an-invocation-no-constructor-can-guess.3"); + assert.equal(surfaceKey("p", "main"), "thing:surface:p.main"); + assert.equal(compositionKey("pub", "card", 2), "thing:composition:pub.card.v2"); + assert.equal(compositionKey("pub", "card", 2n ** 40n), "thing:composition:pub.card.v1099511627776"); + assert.equal(assetCatalogKey("p", "icons"), "thing:asset_catalog:p.icons"); +}); + +test("a result key refuses a seq below 1 and a composition key a version below 0", () => { + assert.throws(() => resultKey("x", 0), /Seq must be at least 1/); + assert.throws(() => compositionKey("pub", "card", -1), /Version must be at least 0/); + assert.equal(compositionKey("pub", "card", 0), "thing:composition:pub.card.v0"); + assert.throws(() => compositionKey("pub", "card", 1.5), /int64/); + assert.throws(() => resultKey("x", 1.5), /int32/); + assert.throws(() => resultKey("x", 2 ** 31), /int32/); +}); + +test("keys are injective over their field sets", () => { + // A dotted provider with a plain label, and a plain provider with a dotted label: the second cannot be built. + assert.equal(surfaceKey("a.b", "c"), "thing:surface:a.b.c"); + assert.throws(() => surfaceKey("a", "b.c"), /Segment/); + assert.notEqual(surfaceKey("a", "c"), surfaceKey("a.b", "c")); + assert.notEqual(surfaceKey("a.b", "c"), surfaceKey("a", "bc")); + + // A composition splits at its last two dots: publisher, name, then the version. + assert.equal(compositionKey("a.b", "c", 1), "thing:composition:a.b.c.v1"); + assert.notEqual(compositionKey("a.b", "c", 1), compositionKey("a", "bc", 1)); + assert.notEqual(compositionKey("a", "c", 1), compositionKey("a", "c", 11)); + assert.notEqual(compositionKey("a", "c", 1), compositionKey("a", "c", 0)); + + // A catalog likewise, under the asset_catalog prefix. + assert.equal(assetCatalogKey("a.b", "c"), "thing:asset_catalog:a.b.c"); + assert.notEqual(assetCatalogKey("a.b", "c"), assetCatalogKey("a", "bc")); + + // A result splits at its last dot, and the seq is digits. + assert.equal(resultKey("x.1", 2), "thing:result:x.1.2"); + assert.equal(resultKey("x", 12), "thing:result:x.12"); + assert.notEqual(resultKey("x.1", 2), resultKey("x", 12)); +}); diff --git a/packages/thing-contract/test/refusals.test.ts b/packages/thing-contract/test/refusals.test.ts new file mode 100644 index 0000000..26e83bf --- /dev/null +++ b/packages/thing-contract/test/refusals.test.ts @@ -0,0 +1,148 @@ +// The refusal goldens and the domain doors. A payload is refused by name (the union and the arm key, or the domain), and +// no message echoes a value: the goldens carry CANARY in the slots a refusal must not repeat. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + decodeDocumentPayload, + defineDocumentType, + documentPayloadFromBytes, + encodeDocumentPayload, +} from "@gamecult/cultcache-ts"; +import { + CommandResult, + assetCatalogDocument, + codecs, + commandResultDocument, + domain, + surfaceDocument, +} from "@gamecult/thing-contract"; + +import { CANARY, assetCatalogValue, fullSurface, golden, invocation } from "./builders.js"; + +const rgbaDocument = defineDocumentType({ + type: "test.rgba", + schema: { parse: (input: unknown) => input }, + codec: codecs.rgba, +}); + +function refusal(run: () => unknown): string { + try { + run(); + } catch (error) { + assert.ok(error instanceof Error); + assert.ok(!error.message.includes(CANARY), `the refusal echoed a value: ${error.message}`); + return error.message; + } + return assert.fail("expected a refusal"); +} + +const read = (definition: Parameters[0], file: string) => () => + decodeDocumentPayload(definition, documentPayloadFromBytes(golden(file))); + +test("a Thing 1 flat map is not a command result", () => { + refusal(read(commandResultDocument, "result-thing1-flat.bin")); +}); + +test("an unknown Node arm is refused naming the union and the key", () => { + assert.match(refusal(read(surfaceDocument, "node-unknown-arm.bin")), /Union Node: unknown arm key 13/); +}); + +test("an unknown Outcome arm is refused naming the union and the key", () => { + assert.match(refusal(read(commandResultDocument, "outcome-unknown-arm.bin")), /Union Outcome: unknown arm key 4/); +}); + +test("an extra trailing slot is read and never written back", () => { + const decoded = decodeDocumentPayload(commandResultDocument, documentPayloadFromBytes(golden("result-extra-slot.bin"))); + const expected = CommandResult.pending({ ...invocation(), invocationId: "inv-1" }, 1, "working"); + assert.deepStrictEqual(decoded, expected); + assert.ok(!JSON.stringify(decoded).includes(CANARY)); +}); + +test("each domain golden is refused naming its domain", () => { + assert.match(refusal(read(commandResultDocument, "result-seq-zero.bin")), /Seq must be at least 1/); + assert.match(refusal(read(surfaceDocument, "surface-version-negative.bin")), /Version must be at least 0/); + assert.match(refusal(read(surfaceDocument, "surface-label-dotted.bin")), /Segment must be free of '\.'/); + assert.match(refusal(read(assetCatalogDocument, "asset-extent-negative.bin")), /Count must be at least 0/); + assert.match(refusal(read(rgbaDocument, "rgba-channel-out-of-range.bin")), /Rgba channel must be 0 to 255/); +}); + +test("the encode door holds the same domains", () => { + const surface = fullSurface(); + const catalog = assetCatalogValue(); + const encodeSurface = (value: unknown) => () => encodeDocumentPayload(surfaceDocument, value); + assert.match(refusal(encodeSurface({ ...surface, label: `${CANARY}.x` })), /Segment must be free of '\.'/); + assert.match(refusal(encodeSurface({ ...surface, label: "" })), /Segment must be non-empty/); + assert.match(refusal(encodeSurface({ ...surface, label: null })), /label/); + assert.match(refusal(encodeSurface({ ...surface, version: -1 })), /Version must be at least 0/); + assert.match(refusal(encodeSurface({ ...surface, version: -(2n ** 40n) })), /Version must be at least 0/); + assert.match( + refusal(() => encodeDocumentPayload(assetCatalogDocument, { ...catalog, assets: [{ ...catalog.assets[0], width: -5 }] })), + /Count must be at least 0/, + ); + assert.match( + refusal(() => encodeDocumentPayload(rgbaDocument, { r: 256, g: 0, b: 0, a: 255 })), + /Rgba channel must be 0 to 255/, + ); + assert.match( + refusal(() => encodeDocumentPayload(rgbaDocument, { r: 0, g: -1, b: 0, a: 255 })), + /Rgba channel must be 0 to 255/, + ); +}); + +test("each domain accepts its boundary values and refuses the step beyond, on both doors", () => { + assert.equal(domain.seq.decode(1), 1); + assert.equal(domain.seq.decode(2 ** 31 - 1), 2 ** 31 - 1); + assert.throws(() => domain.seq.decode(0), /Seq must be at least 1/); + assert.throws(() => domain.seq.decode(2 ** 31), /int32/); + assert.throws(() => domain.seq.encode(0), /Seq must be at least 1/); + assert.equal(domain.seq.encode(1), 1); + assert.equal(domain.version.decode(0), 0); + assert.equal(domain.version.decode(2n ** 62n), 2n ** 62n); + assert.throws(() => domain.version.decode(-1), /Version must be at least 0/); + assert.throws(() => domain.version.encode(-1n), /Version must be at least 0/); + assert.equal(domain.version.encode(0), 0); + assert.equal(domain.count.decode(0), 0); + assert.throws(() => domain.count.decode(-1), /Count must be at least 0/); + assert.throws(() => domain.count.encode(-1), /Count must be at least 0/); + assert.equal(domain.count.encode(0), 0); + assert.equal(domain.segment.decode("a"), "a"); + assert.throws(() => domain.segment.decode(""), /Segment must be non-empty/); + assert.throws(() => domain.segment.decode("."), /Segment must be free of '\.'/); + assert.throws(() => domain.segment.encode("a.b"), /Segment must be free of '\.'/); + assert.throws(() => domain.segment.encode(""), /Segment must be non-empty/); + assert.equal(domain.segment.encode("a"), "a"); + assert.equal(domain.channel.decode(0), 0); + assert.equal(domain.channel.decode(255), 255); + assert.throws(() => domain.channel.decode(256), /Rgba channel must be 0 to 255/); + assert.throws(() => domain.channel.decode(-1), /Rgba channel must be 0 to 255/); + assert.throws(() => domain.channel.encode(256), /Rgba channel must be 0 to 255/); + assert.throws(() => domain.channel.encode(-1), /Rgba channel must be 0 to 255/); + assert.equal(domain.channel.encode(255), 255); + assert.equal(domain.channel.encode(0), 0); +}); + +test("a domain refusal never echoes the value", () => { + for (const run of [ + () => domain.segment.decode(`${CANARY}.x`), + () => domain.seq.decode(-777), + () => domain.version.decode(-777), + () => domain.count.decode(-777), + () => domain.channel.decode(777), + () => domain.seq.encode(-777), + () => domain.channel.encode(777), + ]) { + const message = refusal(run); + assert.ok(!message.includes("777"), message); + } +}); + +test("a domain codec reports the reference's leaf type name", () => { + assert.equal(domain.seq.typeName, "System.Int32"); + assert.equal(domain.count.typeName, "System.Int32"); + assert.equal(domain.channel.typeName, "System.Int32"); + assert.equal(domain.version.typeName, "System.Int64"); + assert.equal(domain.segment.typeName, "System.String"); + assert.equal(domain.version.valueType, true); + assert.equal(domain.segment.valueType, true); +}); diff --git a/packages/thing-contract/test/store.test.ts b/packages/thing-contract/test/store.test.ts new file mode 100644 index 0000000..8b27404 --- /dev/null +++ b/packages/thing-contract/test/store.test.ts @@ -0,0 +1,84 @@ +// surface.cc is a single-file store the Rust crate wrote with one record of each kind under a pinned clock. Opened through +// CultCache with the five definitions it yields five records, each re-encodes to its value golden's bytes, and each record +// key equals the key keys.ts builds from the value. +import assert from "node:assert/strict"; +import { join } from "node:path"; +import test from "node:test"; + +import { CultCache, SingleFileMessagePackBackingStore, documentPayloadBytes } from "@gamecult/cultcache-ts"; +import { + CommandResult, + assetCatalogDocument, + assetCatalogKey, + commandInvocationDocument, + commandResultDocument, + compositionDocument, + compositionKey, + invocationKey, + resultKey, + surfaceDocument, + surfaceKey, + thingContract, +} from "@gamecult/thing-contract"; + +import { PINNED_CLOCK, assetCatalogValue, fullComposition, fullSurface, golden, goldensDir, invocation } from "./builders.js"; + +async function open() { + const cache = CultCache.builder() + .withRegistry(thingContract) + .withGenericStore(new SingleFileMessagePackBackingStore(join(goldensDir, "surface.cc"))) + .build(); + await cache.pullAllBackingStores(); + return cache; +} + +test("surface.cc opens to one record of each of the five kinds", async () => { + const cache = await open(); + const snapshot = cache.snapshot(); + assert.equal(snapshot.length, 5); + assert.deepEqual( + snapshot.map((envelope) => envelope.type).sort(), + [ + "gamecult.thing.asset_catalog", + "gamecult.thing.command_invocation", + "gamecult.thing.command_result", + "gamecult.thing.composition", + "gamecult.thing.surface", + ], + ); + for (const envelope of snapshot) assert.equal(envelope.storedAt, PINNED_CLOCK); +}); + +test("each record equals its value and re-encodes to its value golden", async () => { + const cache = await open(); + const surface = fullSurface(); + const composition = fullComposition(); + const answered = invocation(); + const result = CommandResult.pending(answered, 1, "working"); + const catalog = assetCatalogValue(); + + const surfaceRecordKey = surfaceKey(surface.providerId, surface.label); + const compositionRecordKey = compositionKey(composition.publisher, composition.name, composition.version); + const invocationRecordKey = invocationKey(answered.invocationId); + const resultRecordKey = resultKey(result.invocationId, result.seq); + const catalogRecordKey = assetCatalogKey(catalog.providerId, catalog.catalogId); + + assert.deepStrictEqual(cache.getRequired(surfaceDocument, surfaceRecordKey), surface); + assert.deepStrictEqual(cache.getRequired(compositionDocument, compositionRecordKey), composition); + assert.deepStrictEqual(cache.getRequired(commandInvocationDocument, invocationRecordKey), answered); + assert.deepStrictEqual(cache.getRequired(commandResultDocument, resultRecordKey), result); + assert.deepStrictEqual(cache.getRequired(assetCatalogDocument, catalogRecordKey), catalog); + + const goldenOf: Record = { + [surfaceRecordKey]: "surface-all-arms.bin", + [compositionRecordKey]: "composition-all-params.bin", + [invocationRecordKey]: "invocation-all-inputs.bin", + [resultRecordKey]: "result-pending.bin", + [catalogRecordKey]: "asset-catalog.bin", + }; + const snapshot = cache.snapshot(); + assert.deepEqual(snapshot.map((envelope) => envelope.key).sort(), Object.keys(goldenOf).sort()); + for (const envelope of snapshot) { + assert.deepEqual(documentPayloadBytes(envelope.payload), golden(goldenOf[envelope.key]), envelope.key); + } +}); diff --git a/packages/thing-contract/test/types.ts b/packages/thing-contract/test/types.ts new file mode 100644 index 0000000..307a586 --- /dev/null +++ b/packages/thing-contract/test/types.ts @@ -0,0 +1,45 @@ +// Compile-time half of the construction rules, compiled by tsconfig.test.json: every @ts-expect-error line must be an +// error, or the compile fails. This file runs nothing. +import { CommandResult } from "@gamecult/thing-contract"; +import type { CommandInvocation, Handler, Outcome } from "@gamecult/thing-contract"; + +declare const invocation: CommandInvocation; +declare const result: CommandResult; +declare const plain: { invocationId: string; seq: number; outcome: Outcome }; + +// An object literal does not satisfy CommandResult, because the class carries a private brand. +// @ts-expect-error +export const literal: CommandResult = { invocationId: "x", seq: 1, outcome: { kind: "Failed", reason: "r" } }; + +// Nor does a plain value of the same shape, nor a spread of a result. +// @ts-expect-error +export const shaped: CommandResult = plain; +// @ts-expect-error +export const spread: CommandResult = { ...result }; + +// The constructor is private: even a call whose arguments all type-check (the door argument is typed `never` here) is +// refused, so the error is the access, not the arity. +// @ts-expect-error +export const constructed = new CommandResult(undefined as never, "x", 1, { kind: "Failed", reason: "r" }); + +// The fields are read-only. +// @ts-expect-error +result.seq = 2; +// @ts-expect-error +result.invocationId = "y"; +// @ts-expect-error +result.outcome = { kind: "Failed", reason: "r" }; + +// An Outcome is a tagged value, never a state string. +// @ts-expect-error +export const stringOutcome: Outcome = "completed"; +// @ts-expect-error +export const stringFactory = CommandResult.failed(invocation, 1, "r", "completed", 3); +// @ts-expect-error +export const noInvocation = CommandResult.failed("inv-1", 1, "r"); +// @ts-expect-error +export const stateInHandler: Handler = (): string => "completed"; + +// The positive cases compile. +export const built: CommandResult = CommandResult.failed(invocation, 1, "r"); +export const handler: Handler = async () => ({ kind: "Pending", message: "m" }); diff --git a/packages/thing-contract/tsconfig.json b/packages/thing-contract/tsconfig.json new file mode 100644 index 0000000..d7fbb68 --- /dev/null +++ b/packages/thing-contract/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "declaration": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "newLine": "lf", + "noEmitOnError": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "target": "ES2022" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/thing-contract/tsconfig.test.json b/packages/thing-contract/tsconfig.test.json new file mode 100644 index 0000000..f833f44 --- /dev/null +++ b/packages/thing-contract/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "newLine": "lf", + "noEmitOnError": true, + "outDir": "dist-test", + "rootDir": ".", + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["test/**/*.ts", "src/**/*.ts"] +}