From c7ac1791ec0b5c6cc7bb35ede378601594480c5c Mon Sep 17 00:00:00 2001 From: Rafael Campos Date: Tue, 24 Feb 2026 12:55:20 -0500 Subject: [PATCH 1/5] feat: kotlin types --- .circleci/config.yml | 2 +- BUILD.bazel | 7 + MODULE.bazel | 20 + MODULE.bazel.lock | 93 +-- types/kotlin/.editorconfig | 32 + types/kotlin/BUILD.bazel | 66 ++ .../intuit/playerui/xlr/XlrDeserializer.kt | 532 ++++++++++++++++ .../com/intuit/playerui/xlr/XlrGuards.kt | 102 ++++ .../com/intuit/playerui/xlr/XlrSerializer.kt | 285 +++++++++ .../com/intuit/playerui/xlr/XlrTypes.kt | 494 +++++++++++++++ .../com/intuit/playerui/xlr/XlrUtility.kt | 49 ++ .../com/intuit/playerui/xlr/AllTests.kt | 13 + .../playerui/xlr/XlrDeserializerTest.kt | 567 ++++++++++++++++++ .../com/intuit/playerui/xlr/XlrGuardsTest.kt | 223 +++++++ .../intuit/playerui/xlr/XlrSerializerTest.kt | 458 ++++++++++++++ .../com/intuit/playerui/xlr/XlrTypesTest.kt | 268 +++++++++ types/kotlin/src/test/resources/test.json | 191 ++++++ 17 files changed, 3310 insertions(+), 92 deletions(-) create mode 100644 types/kotlin/.editorconfig create mode 100644 types/kotlin/BUILD.bazel create mode 100644 types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrDeserializer.kt create mode 100644 types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrGuards.kt create mode 100644 types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrSerializer.kt create mode 100644 types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrTypes.kt create mode 100644 types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrUtility.kt create mode 100644 types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/AllTests.kt create mode 100644 types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrDeserializerTest.kt create mode 100644 types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrGuardsTest.kt create mode 100644 types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrSerializerTest.kt create mode 100644 types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrTypesTest.kt create mode 100644 types/kotlin/src/test/resources/test.json diff --git a/.circleci/config.yml b/.circleci/config.yml index d7436be..9bc3401 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -101,7 +101,7 @@ jobs: at: ~/xlr - run: | - bazel coverage --config=ci -- $(bazel query "kind(js_test, //...) + kind(py_test, //...)" --output label 2>/dev/null | tr '\n' ' ') + bazel coverage --config=ci -- $(bazel query "kind(js_test, //...) + kind(py_test, //...) + kind(kt_jvm_test, //...) + kind(_ktlint_test, //...)" --output label 2>/dev/null | tr '\n' ' ') - run: when: always diff --git a/BUILD.bazel b/BUILD.bazel index a6a3dc5..fc9114f 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -2,6 +2,7 @@ load("@aspect_rules_js//js:defs.bzl", "js_library") load("@aspect_rules_ts//ts:defs.bzl", "ts_config") load("@npm//:defs.bzl", "npm_link_all_packages") load("@npm//:tsconfig-to-swcconfig/package_json.bzl", tsconfig_to_swcconfig = "bin") +load("@rules_kotlin//kotlin:core.bzl", "kt_kotlinc_options") package(default_visibility = ["//visibility:public"]) @@ -97,3 +98,9 @@ tsconfig_to_swcconfig.t2s( stdout = ".swcrc", visibility = ["//:__subpackages__"], ) + +kt_kotlinc_options( + name = "kt_opts", + jvm_target = "21", + visibility = ["//visibility:public"], +) diff --git a/MODULE.bazel b/MODULE.bazel index f0928ea..8c3a0ff 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -66,6 +66,26 @@ pip.parse( use_repo(pip, "pypi") +####### Kotlin ######### +bazel_dep(name = "rules_kotlin", version = "2.2.1") +bazel_dep(name = "rules_jvm_external", version = "6.7") + +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") +maven.install( + artifacts = [ + "org.jetbrains.kotlin:kotlin-stdlib:2.3.0", + "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0", + "org.jetbrains.kotlin:kotlin-test:2.3.0", + "org.jetbrains.kotlin:kotlin-test-junit:2.3.0", + "junit:junit:4.13.2", + ], + repositories = [ + "https://repo1.maven.org/maven2", + ], +) +use_repo(maven, "maven") +######################## + build_constants = use_repo_rule("@rules_player//distribution:defs.bzl", "build_constants") build_constants( name = "build_constants", diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 77c5f34..7632427 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -196,7 +196,8 @@ "https://bcr.bazel.build/modules/rules_kotlin/1.9.5/MODULE.bazel": "043a16a572f610558ec2030db3ff0c9938574e7dd9f58bded1bb07c0192ef025", "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", "https://bcr.bazel.build/modules/rules_kotlin/2.1.8/MODULE.bazel": "e832456b08f4bdf23a466ae4ec424ce700d4ccaa2c9e83912d32791ade43b77d", - "https://bcr.bazel.build/modules/rules_kotlin/2.1.8/source.json": "4ccee7dee20c21546ae4ea3d26d4f9c859fc906fa4c8e317d571b62d8048ae10", + "https://bcr.bazel.build/modules/rules_kotlin/2.2.1/MODULE.bazel": "e6e2dc739345a74a0946056d0bf03a3e15080da8f54e872404c944f777803e10", + "https://bcr.bazel.build/modules/rules_kotlin/2.2.1/source.json": "2a549f7f39f66a07d22081346bc51053b45f413ede77a34b3a21b13e1d20b320", "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0", "https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d", "https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c", @@ -433,96 +434,6 @@ "recordedRepoMappingEntries": [] } }, - "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { - "general": { - "bzlTransitiveDigest": "vfLCTchDthU74iCKvoskQ+ovk2Wu2tLykbCddrcLy7U=", - "usagesDigest": "QPppUlwb7NSBhcaYae+JZPqTEmJKCkOXKFPXQS7aAJE=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, - "generatedRepoSpecs": { - "com_github_jetbrains_kotlin_git": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", - "attributes": { - "urls": [ - "https://github.com/JetBrains/kotlin/releases/download/v2.1.21/kotlin-compiler-2.1.21.zip" - ], - "sha256": "1ba08a8b45da99339a0601134cc037b54cf85e9bc0edbe76dcbd27c2d684a977" - } - }, - "com_github_jetbrains_kotlin": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_capabilities_repository", - "attributes": { - "git_repository_name": "com_github_jetbrains_kotlin_git", - "compiler_version": "2.1.21" - } - }, - "com_github_google_ksp": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:ksp.bzl%ksp_compiler_plugin_repository", - "attributes": { - "urls": [ - "https://github.com/google/ksp/releases/download/2.1.21-2.0.1/artifacts.zip" - ], - "sha256": "44e965bb067b2bb5cd9184dab2c3dea6e3eab747d341c07645bb4c88f09e49c8", - "strip_version": "2.1.21-2.0.1" - } - }, - "com_github_pinterest_ktlint": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", - "attributes": { - "sha256": "5ba1ac917a06b0f02daaa60d10abbedd2220d60216af670c67a45b91c74cf8bb", - "urls": [ - "https://github.com/pinterest/ktlint/releases/download/1.6.0/ktlint" - ], - "executable": true - } - }, - "kotlinx_serialization_core_jvm": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", - "attributes": { - "sha256": "3565b6d4d789bf70683c45566944287fc1d8dc75c23d98bd87d01059cc76f2b3", - "urls": [ - "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-core-jvm/1.8.1/kotlinx-serialization-core-jvm-1.8.1.jar" - ] - } - }, - "kotlinx_serialization_json": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", - "attributes": { - "sha256": "58adf3358a0f99dd8d66a550fbe19064d395e0d5f7f1e46515cd3470a56fbbb0", - "urls": [ - "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-json/1.8.1/kotlinx-serialization-json-1.8.1.jar" - ] - } - }, - "kotlinx_serialization_json_jvm": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", - "attributes": { - "sha256": "8769e5647557e3700919c32d508f5c5dad53c5d8234cd10846354fbcff14aa24", - "urls": [ - "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-json-jvm/1.8.1/kotlinx-serialization-json-jvm-1.8.1.jar" - ] - } - }, - "kotlin_build_tools_impl": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", - "attributes": { - "sha256": "6e94896e321603e3bfe89fef02478e44d1d64a3d25d49d0694892ffc01c60acf", - "urls": [ - "https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-build-tools-impl/2.1.20/kotlin-build-tools-impl-2.1.20.jar" - ] - } - } - }, - "recordedRepoMappingEntries": [ - [ - "rules_kotlin+", - "bazel_tools", - "bazel_tools" - ] - ] - } - }, "@@rules_nodejs+//nodejs:extensions.bzl%node": { "general": { "bzlTransitiveDigest": "NwcLXHrbh2hoorA/Ybmcpjxsn/6avQmewDglodkDrgo=", diff --git a/types/kotlin/.editorconfig b/types/kotlin/.editorconfig new file mode 100644 index 0000000..9eab273 --- /dev/null +++ b/types/kotlin/.editorconfig @@ -0,0 +1,32 @@ +# EditorConfig for Kotlin code style +# https://editorconfig.org/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +trim_trailing_whitespace = true + +[*.kt] +# ktlint specific rules +ktlint_standard_no-wildcard-imports = disabled +ktlint_standard_package-name = disabled + +# Allow trailing commas +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled + +# Function signature - allow multi-line +ktlint_standard_function-signature = disabled + +# Disable some strict rules for DSL-style code +ktlint_standard_parameter-list-wrapping = disabled +ktlint_standard_argument-list-wrapping = disabled + +[*.kts] +ktlint_standard_no-wildcard-imports = disabled diff --git a/types/kotlin/BUILD.bazel b/types/kotlin/BUILD.bazel new file mode 100644 index 0000000..d113269 --- /dev/null +++ b/types/kotlin/BUILD.bazel @@ -0,0 +1,66 @@ +load("@rules_kotlin//kotlin:core.bzl", "kt_compiler_plugin") +load("@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library", "kt_jvm_test") +load("@rules_kotlin//kotlin:lint.bzl", "ktlint_config", "ktlint_fix", "ktlint_test") + +package(default_visibility = ["//visibility:public"]) + +ktlint_config( + name = "ktlint_config", + editorconfig = ".editorconfig", +) + +kt_compiler_plugin( + name = "kotlinx_serialization_plugin", + compile_phase = True, + id = "org.jetbrains.kotlin.serialization", + stubs_phase = True, + deps = [ + "@rules_kotlin//kotlin/compiler:kotlin-serialization-compiler-plugin", + ], +) + +kt_jvm_library( + name = "xlr-types", + srcs = glob(["src/main/kotlin/**/*.kt"]), + kotlinc_opts = "//:kt_opts", + plugins = [":kotlinx_serialization_plugin"], + deps = [ + "@maven//:org_jetbrains_kotlin_kotlin_stdlib", + "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", + ], +) + +kt_jvm_test( + name = "xlr-types-test", + srcs = glob(["src/test/kotlin/**/*.kt"]), + kotlinc_opts = "//:kt_opts", + plugins = [":kotlinx_serialization_plugin"], + resource_strip_prefix = "types/kotlin/src/test/resources", + resources = glob(["src/test/resources/**"]), + test_class = "com.intuit.playerui.xlr.AllTests", + deps = [ + ":xlr-types", + "@maven//:junit_junit", + "@maven//:org_jetbrains_kotlin_kotlin_test", + "@maven//:org_jetbrains_kotlin_kotlin_test_junit", + "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", + ], +) + +ktlint_test( + name = "ktlint", + srcs = glob([ + "src/main/kotlin/**/*.kt", + "src/test/kotlin/**/*.kt", + ]), + config = ":ktlint_config", +) + +ktlint_fix( + name = "ktlint_fix", + srcs = glob([ + "src/main/kotlin/**/*.kt", + "src/test/kotlin/**/*.kt", + ]), + config = ":ktlint_config", +) diff --git a/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrDeserializer.kt b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrDeserializer.kt new file mode 100644 index 0000000..99ae411 --- /dev/null +++ b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrDeserializer.kt @@ -0,0 +1,532 @@ +package com.intuit.playerui.xlr + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object XlrDeserializer { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun deserialize(jsonString: String): XlrDocument { + val element = json.parseToJsonElement(jsonString).jsonObject + return parseDocument(element) + } + + fun parseDocument(obj: JsonObject): XlrDocument { + val annotations = parseAnnotations(obj) + val objectType = + ObjectType( + properties = parseProperties(obj["properties"]), + extends = obj["extends"]?.let { parseRefType(it.jsonObject) }, + additionalProperties = parseAdditionalItems(obj["additionalProperties"]), + name = annotations.name, + title = annotations.title, + description = annotations.description, + examples = annotations.examples, + default = annotations.default, + see = annotations.see, + comment = annotations.comment, + meta = annotations.meta, + source = annotations.source, + genericTokens = annotations.genericTokens, + ) + return XlrDocument( + name = + obj["name"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException("Document missing 'name'"), + source = + obj["source"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException("Document missing 'source'"), + objectType = objectType, + genericTokens = + obj["genericTokens"]?.takeIf { it !is JsonNull }?.jsonArray?.map { + parseParamTypeNode(it.jsonObject) + }, + ) + } + + fun parseNode(element: JsonElement): NodeType { + if (element is JsonNull) { + return NullType() + } + + val obj = element.jsonObject + val type = + obj["type"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException("Node missing 'type' field: $obj") + + return when (type) { + "string" -> parseStringType(obj) + "number" -> parseNumberType(obj) + "boolean" -> parseBooleanType(obj) + "null" -> parseNullType(obj) + "any" -> parseAnyType(obj) + "unknown" -> parseUnknownType(obj) + "undefined" -> parseUndefinedType(obj) + "void" -> parseVoidType(obj) + "never" -> parseNeverType(obj) + "ref" -> parseRefType(obj) + "object" -> parseObjectType(obj) + "array" -> parseArrayType(obj) + "tuple" -> parseTupleType(obj) + "record" -> parseRecordType(obj) + "or" -> parseOrType(obj) + "and" -> parseAndType(obj) + "template" -> parseTemplateLiteralType(obj) + "conditional" -> parseConditionalType(obj) + "function" -> parseFunctionType(obj) + else -> throw IllegalArgumentException("Unknown type: $type in object: $obj") + } + } + + private data class ParsedAnnotations( + val name: String? = null, + val title: String? = null, + val description: String? = null, + val examples: JsonElement? = null, + val default: JsonElement? = null, + val see: JsonElement? = null, + val comment: String? = null, + val meta: Map? = null, + val source: String? = null, + val genericTokens: List? = null, + ) + + private fun parseAnnotations(obj: JsonObject): ParsedAnnotations = + ParsedAnnotations( + name = obj["name"]?.jsonPrimitive?.contentOrNull, + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + examples = obj["examples"]?.takeIf { it !is JsonNull }, + default = obj["default"]?.takeIf { it !is JsonNull }, + see = obj["see"]?.takeIf { it !is JsonNull }, + comment = obj["comment"]?.jsonPrimitive?.contentOrNull, + meta = + obj["meta"]?.takeIf { it !is JsonNull }?.jsonObject?.mapValues { + it.value.jsonPrimitive.content + }, + source = obj["source"]?.jsonPrimitive?.contentOrNull, + genericTokens = + obj["genericTokens"]?.takeIf { it !is JsonNull }?.jsonArray?.map { + parseParamTypeNode(it.jsonObject) + }, + ) + + private fun parseAdditionalItems(element: JsonElement?): AdditionalItemsType { + if (element == null || element is JsonNull) return AdditionalItemsType.None + if (element is JsonPrimitive && element.booleanOrNull == false) return AdditionalItemsType.None + return AdditionalItemsType.Typed(parseNode(element)) + } + + private fun parseStringType(obj: JsonObject): StringType { + val a = parseAnnotations(obj) + return StringType( + const = obj["const"]?.jsonPrimitive?.contentOrNull, + enum = obj["enum"]?.jsonArray?.map { it.jsonPrimitive.content }, + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseNumberType(obj: JsonObject): NumberType { + val a = parseAnnotations(obj) + return NumberType( + const = obj["const"]?.jsonPrimitive?.doubleOrNull, + enum = + obj["enum"]?.jsonArray?.map { + it.jsonPrimitive.doubleOrNull + ?: throw IllegalArgumentException("Invalid number in enum: ${it.jsonPrimitive.content}") + }, + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseBooleanType(obj: JsonObject): BooleanType { + val a = parseAnnotations(obj) + return BooleanType( + const = obj["const"]?.jsonPrimitive?.booleanOrNull, + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseNullType(obj: JsonObject): NullType { + val a = parseAnnotations(obj) + return NullType( + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseAnyType(obj: JsonObject): AnyType { + val a = parseAnnotations(obj) + return AnyType( + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseUnknownType(obj: JsonObject): UnknownType { + val a = parseAnnotations(obj) + return UnknownType( + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseUndefinedType(obj: JsonObject): UndefinedType { + val a = parseAnnotations(obj) + return UndefinedType( + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseVoidType(obj: JsonObject): VoidType { + val a = parseAnnotations(obj) + return VoidType( + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseNeverType(obj: JsonObject): NeverType { + val a = parseAnnotations(obj) + return NeverType( + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseRefType(obj: JsonObject): RefType { + val a = parseAnnotations(obj) + return RefType( + ref = + obj["ref"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException("RefType missing 'ref': $obj"), + genericArguments = obj["genericArguments"]?.jsonArray?.map { parseNode(it) }, + property = obj["property"]?.jsonPrimitive?.contentOrNull, + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseObjectType(obj: JsonObject): ObjectType { + val a = parseAnnotations(obj) + return ObjectType( + properties = parseProperties(obj["properties"]), + extends = obj["extends"]?.let { parseRefType(it.jsonObject) }, + additionalProperties = parseAdditionalItems(obj["additionalProperties"]), + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseArrayType(obj: JsonObject): ArrayType { + val elementType = + obj["elementType"] + ?: throw IllegalArgumentException("Array missing 'elementType': $obj") + val a = parseAnnotations(obj) + return ArrayType( + elementType = parseNode(elementType), + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseTupleType(obj: JsonObject): TupleType { + val a = parseAnnotations(obj) + return TupleType( + elementTypes = + obj["elementTypes"]?.jsonArray?.map { parseTupleMember(it.jsonObject) } + ?: emptyList(), + minItems = obj["minItems"]?.jsonPrimitive?.int ?: 0, + additionalItems = parseAdditionalItems(obj["additionalItems"]), + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseTupleMember(obj: JsonObject): TupleMember = + TupleMember( + name = obj["name"]?.jsonPrimitive?.contentOrNull, + type = parseNode(obj["type"] ?: throw IllegalArgumentException("TupleMember missing 'type'")), + optional = obj["optional"]?.jsonPrimitive?.booleanOrNull, + ) + + private fun parseRecordType(obj: JsonObject): RecordType { + val a = parseAnnotations(obj) + return RecordType( + keyType = parseNode(obj["keyType"] ?: throw IllegalArgumentException("Record missing 'keyType'")), + valueType = parseNode(obj["valueType"] ?: throw IllegalArgumentException("Record missing 'valueType'")), + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseOrType(obj: JsonObject): OrType { + val a = parseAnnotations(obj) + return OrType( + orTypes = obj["or"]?.jsonArray?.map { parseNode(it) } ?: emptyList(), + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseAndType(obj: JsonObject): AndType { + val a = parseAnnotations(obj) + return AndType( + andTypes = obj["and"]?.jsonArray?.map { parseNode(it) } ?: emptyList(), + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseTemplateLiteralType(obj: JsonObject): TemplateLiteralType { + val a = parseAnnotations(obj) + return TemplateLiteralType( + format = + obj["format"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException("TemplateLiteralType missing 'format': $obj"), + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseConditionalType(obj: JsonObject): ConditionalType { + val a = parseAnnotations(obj) + val checkObj = + obj["check"]?.jsonObject + ?: throw IllegalArgumentException("Conditional missing 'check': $obj") + val valueObj = + obj["value"]?.jsonObject + ?: throw IllegalArgumentException("Conditional missing 'value': $obj") + return ConditionalType( + check = + ConditionalCheck( + left = parseNode(checkObj["left"] ?: throw IllegalArgumentException("check missing 'left'")), + right = parseNode(checkObj["right"] ?: throw IllegalArgumentException("check missing 'right'")), + ), + value = + ConditionalValue( + trueValue = parseNode(valueObj["true"] ?: throw IllegalArgumentException("value missing 'true'")), + falseValue = + parseNode( + valueObj["false"] + ?: throw IllegalArgumentException("value missing 'false'"), + ), + ), + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseFunctionType(obj: JsonObject): FunctionType { + val a = parseAnnotations(obj) + return FunctionType( + parameters = + obj["parameters"]?.jsonArray?.map { parseFunctionParameter(it.jsonObject) } + ?: emptyList(), + returnType = obj["returnType"]?.let { parseNode(it) }, + name = a.name, + title = a.title, + description = a.description, + examples = a.examples, + default = a.default, + see = a.see, + comment = a.comment, + meta = a.meta, + source = a.source, + genericTokens = a.genericTokens, + ) + } + + private fun parseFunctionParameter(obj: JsonObject): FunctionParameter = + FunctionParameter( + name = + obj["name"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException("FunctionParameter missing 'name': $obj"), + type = parseNode(obj["type"] ?: throw IllegalArgumentException("Parameter missing 'type'")), + optional = obj["optional"]?.jsonPrimitive?.booleanOrNull, + default = obj["default"]?.takeIf { it !is JsonNull }?.let { parseNode(it) }, + ) + + private fun parseProperties(element: JsonElement?): Map { + if (element == null || element is JsonNull) return emptyMap() + return element.jsonObject.mapValues { (_, propElement) -> + val propObj = propElement.jsonObject + ObjectProperty( + required = propObj["required"]?.jsonPrimitive?.boolean ?: false, + node = parseNode(propObj["node"] ?: throw IllegalArgumentException("Property missing 'node'")), + ) + } + } + + private fun parseParamTypeNode(obj: JsonObject): ParamTypeNode = + ParamTypeNode( + symbol = + obj["symbol"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException("ParamTypeNode missing 'symbol': $obj"), + constraints = obj["constraints"]?.takeIf { it !is JsonNull }?.let { parseNode(it) }, + default = obj["default"]?.takeIf { it !is JsonNull }?.let { parseNode(it) }, + ) +} diff --git a/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrGuards.kt b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrGuards.kt new file mode 100644 index 0000000..5460957 --- /dev/null +++ b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrGuards.kt @@ -0,0 +1,102 @@ +package com.intuit.playerui.xlr + +// Composite guard and utility functions for XLR node types. +// Trivial single-type checks (e.g. `node is StringType`) are not included +// because Kotlin's `is` operator with smart casting makes them redundant. + +/** + * Check if a node is a primitive type (string, number, boolean, null, etc.) + */ +fun isPrimitiveType(node: NodeType): Boolean = + when (node) { + is StringType, is NumberType, is BooleanType, is NullType, + is AnyType, is UnknownType, is UndefinedType, is VoidType, is NeverType, + -> true + + else -> false + } + +/** + * Check if a ref node references an AssetWrapper type. + */ +fun isAssetWrapperRef(node: NodeType): Boolean { + if (node !is RefType) return false + return node.ref.startsWith("AssetWrapper") +} + +/** + * Check if a ref node references an Asset type. + */ +fun isAssetRef(node: NodeType): Boolean { + if (node !is RefType) return false + return node.ref.startsWith("Asset<") || node.ref == "Asset" +} + +/** + * Check if a ref node references a Binding type. + */ +fun isBindingRef(node: NodeType): Boolean { + if (node !is RefType) return false + return node.ref == "Binding" || node.ref.startsWith("Binding<") +} + +/** + * Check if a ref node references an Expression type. + */ +fun isExpressionRef(node: NodeType): Boolean { + if (node !is RefType) return false + return node.ref == "Expression" || node.ref.startsWith("Expression<") +} + +/** + * Extract the asset type constant from an extends clause. + * E.g., Asset<"action"> -> "action" + */ +fun extractAssetTypeConstant(extendsRef: RefType?): String? { + if (extendsRef == null) return null + if (!extendsRef.ref.startsWith("Asset<")) return null + + val genericArgs = extendsRef.genericArguments ?: return null + if (genericArgs.isEmpty()) return null + + val firstArg = genericArgs.first() + if (firstArg is StringType && firstArg.const != null) { + return firstArg.const + } + + return null +} + +/** + * Check if a string type has a const value (literal type). + */ +fun hasConstValue(node: StringType): Boolean = node.const != null + +/** + * Check if a node has any const value. + */ +fun hasAnyConstValue(node: NodeType): Boolean = + when (node) { + is StringType -> node.const != null + is NumberType -> node.const != null + is BooleanType -> node.const != null + else -> false + } + +/** + * Check if an OrType contains only primitives with const values (Literal type). + */ +fun isLiteralUnion(node: OrType): Boolean = node.orTypes.all { hasAnyConstValue(it) } + +/** + * Get all const values from a literal union. + */ +fun getLiteralValues(node: OrType): List = + node.orTypes.mapNotNull { type -> + when (type) { + is StringType -> type.const + is NumberType -> type.const + is BooleanType -> type.const + else -> null + } + } diff --git a/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrSerializer.kt b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrSerializer.kt new file mode 100644 index 0000000..628be52 --- /dev/null +++ b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrSerializer.kt @@ -0,0 +1,285 @@ +package com.intuit.playerui.xlr + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject + +object XlrSerializer { + fun serialize(document: XlrDocument): String = serializeDocument(document).toString() + + fun serializeDocument(document: XlrDocument): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("object")) + + val objType = document.objectType + put("properties", serializeProperties(objType.properties)) + objType.extends?.let { put("extends", serializeNode(it)) } + putAdditionalItems("additionalProperties", objType.additionalProperties) + putAnnotations(objType) + + // Document-level fields take precedence over objectType annotations + put("source", JsonPrimitive(document.source)) + put("name", JsonPrimitive(document.name)) + document.genericTokens?.let { put("genericTokens", serializeParamTypeNodes(it)) } + } + + fun serializeNode(node: NodeType): JsonObject = + when (node) { + is StringType -> serializeStringType(node) + is NumberType -> serializeNumberType(node) + is BooleanType -> serializeBooleanType(node) + is NullType -> serializeNullType(node) + is AnyType -> serializeAnyType(node) + is UnknownType -> serializeUnknownType(node) + is UndefinedType -> serializeUndefinedType(node) + is VoidType -> serializeVoidType(node) + is NeverType -> serializeNeverType(node) + is RefType -> serializeRefType(node) + is ObjectType -> serializeObjectType(node) + is ArrayType -> serializeArrayType(node) + is TupleType -> serializeTupleType(node) + is RecordType -> serializeRecordType(node) + is OrType -> serializeOrType(node) + is AndType -> serializeAndType(node) + is TemplateLiteralType -> serializeTemplateLiteralType(node) + is ConditionalType -> serializeConditionalType(node) + is FunctionType -> serializeFunctionType(node) + } + + private fun kotlinx.serialization.json.JsonObjectBuilder.putAnnotations(node: NodeType) { + node.source?.let { put("source", JsonPrimitive(it)) } + node.name?.let { put("name", JsonPrimitive(it)) } + node.title?.let { put("title", JsonPrimitive(it)) } + node.description?.let { put("description", JsonPrimitive(it)) } + node.examples?.let { put("examples", it) } + node.default?.let { put("default", it) } + node.see?.let { put("see", it) } + node.comment?.let { put("comment", JsonPrimitive(it)) } + node.meta?.let { metaMap -> + put( + "meta", + buildJsonObject { + metaMap.forEach { (k, v) -> put(k, JsonPrimitive(v)) } + }, + ) + } + node.genericTokens?.let { put("genericTokens", serializeParamTypeNodes(it)) } + } + + private fun kotlinx.serialization.json.JsonObjectBuilder.putAdditionalItems( + key: String, + items: AdditionalItemsType, + ) { + when (items) { + is AdditionalItemsType.None -> put(key, JsonPrimitive(false)) + is AdditionalItemsType.Typed -> put(key, serializeNode(items.node)) + } + } + + private fun serializeStringType(node: StringType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("string")) + node.const?.let { put("const", JsonPrimitive(it)) } + node.enum?.let { put("enum", buildJsonArray { it.forEach { v -> add(JsonPrimitive(v)) } }) } + putAnnotations(node) + } + + private fun serializeNumberType(node: NumberType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("number")) + node.const?.let { put("const", doubleToJsonPrimitive(it)) } + node.enum?.let { put("enum", buildJsonArray { it.forEach { v -> add(doubleToJsonPrimitive(v)) } }) } + putAnnotations(node) + } + + private fun serializeBooleanType(node: BooleanType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("boolean")) + node.const?.let { put("const", JsonPrimitive(it)) } + putAnnotations(node) + } + + private fun serializeNullType(node: NullType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("null")) + putAnnotations(node) + } + + private fun serializeAnyType(node: AnyType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("any")) + putAnnotations(node) + } + + private fun serializeUnknownType(node: UnknownType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("unknown")) + putAnnotations(node) + } + + private fun serializeUndefinedType(node: UndefinedType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("undefined")) + putAnnotations(node) + } + + private fun serializeVoidType(node: VoidType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("void")) + putAnnotations(node) + } + + private fun serializeNeverType(node: NeverType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("never")) + putAnnotations(node) + } + + private fun serializeRefType(node: RefType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("ref")) + put("ref", JsonPrimitive(node.ref)) + node.genericArguments?.let { + put("genericArguments", buildJsonArray { it.forEach { n -> add(serializeNode(n)) } }) + } + node.property?.let { put("property", JsonPrimitive(it)) } + putAnnotations(node) + } + + private fun serializeObjectType(node: ObjectType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("object")) + put("properties", serializeProperties(node.properties)) + node.extends?.let { put("extends", serializeNode(it)) } + putAdditionalItems("additionalProperties", node.additionalProperties) + putAnnotations(node) + } + + private fun serializeArrayType(node: ArrayType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("array")) + put("elementType", serializeNode(node.elementType)) + putAnnotations(node) + } + + private fun serializeTupleType(node: TupleType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("tuple")) + put("elementTypes", buildJsonArray { node.elementTypes.forEach { add(serializeTupleMember(it)) } }) + put("minItems", JsonPrimitive(node.minItems)) + putAdditionalItems("additionalItems", node.additionalItems) + putAnnotations(node) + } + + private fun serializeRecordType(node: RecordType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("record")) + put("keyType", serializeNode(node.keyType)) + put("valueType", serializeNode(node.valueType)) + putAnnotations(node) + } + + private fun serializeOrType(node: OrType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("or")) + put("or", buildJsonArray { node.orTypes.forEach { add(serializeNode(it)) } }) + putAnnotations(node) + } + + private fun serializeAndType(node: AndType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("and")) + put("and", buildJsonArray { node.andTypes.forEach { add(serializeNode(it)) } }) + putAnnotations(node) + } + + private fun serializeTemplateLiteralType(node: TemplateLiteralType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("template")) + put("format", JsonPrimitive(node.format)) + putAnnotations(node) + } + + private fun serializeConditionalType(node: ConditionalType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("conditional")) + put( + "check", + buildJsonObject { + put("left", serializeNode(node.check.left)) + put("right", serializeNode(node.check.right)) + }, + ) + put( + "value", + buildJsonObject { + put("true", serializeNode(node.value.trueValue)) + put("false", serializeNode(node.value.falseValue)) + }, + ) + putAnnotations(node) + } + + private fun serializeFunctionType(node: FunctionType): JsonObject = + buildJsonObject { + put("type", JsonPrimitive("function")) + put( + "parameters", + buildJsonArray { + node.parameters.forEach { add(serializeFunctionParameter(it)) } + }, + ) + node.returnType?.let { put("returnType", serializeNode(it)) } + putAnnotations(node) + } + + private fun serializeFunctionParameter(param: FunctionParameter): JsonObject = + buildJsonObject { + put("name", JsonPrimitive(param.name)) + put("type", serializeNode(param.type)) + param.optional?.let { put("optional", JsonPrimitive(it)) } + param.default?.let { put("default", serializeNode(it)) } + } + + private fun serializeProperties(properties: Map): JsonObject = + buildJsonObject { + properties.forEach { (key, prop) -> + put(key, serializeObjectProperty(prop)) + } + } + + private fun serializeObjectProperty(prop: ObjectProperty): JsonObject = + buildJsonObject { + put("required", JsonPrimitive(prop.required)) + put("node", serializeNode(prop.node)) + } + + private fun serializeTupleMember(member: TupleMember): JsonObject = + buildJsonObject { + member.name?.let { put("name", JsonPrimitive(it)) } + put("type", serializeNode(member.type)) + member.optional?.let { put("optional", JsonPrimitive(it)) } + } + + private fun doubleToJsonPrimitive(value: Double): JsonPrimitive = + if (value.isFinite() && value % 1.0 == 0.0) { + JsonPrimitive(value.toLong()) + } else { + JsonPrimitive(value) + } + + private fun serializeParamTypeNodes(tokens: List): JsonArray = + buildJsonArray { + tokens.forEach { token -> + add( + buildJsonObject { + put("symbol", JsonPrimitive(token.symbol)) + token.constraints?.let { put("constraints", serializeNode(it)) } + token.default?.let { put("default", serializeNode(it)) } + }, + ) + } + } +} diff --git a/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrTypes.kt b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrTypes.kt new file mode 100644 index 0000000..fc6bfe0 --- /dev/null +++ b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrTypes.kt @@ -0,0 +1,494 @@ +package com.intuit.playerui.xlr + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass + +/** + * Interface for annotation fields on XLR types. + * Mirrors the JS Annotations interface with all 8 annotation properties. + */ +interface Annotated { + val name: String? + val title: String? + val description: String? + val examples: JsonElement? + val default: JsonElement? + val see: JsonElement? + val comment: String? + val meta: Map? +} + +/** + * Sealed type representing `false | NodeType` for additional properties/items. + */ +@Serializable +sealed interface AdditionalItemsType { + @Serializable + data object None : AdditionalItemsType + + @Serializable + data class Typed( + val node: NodeType, + ) : AdditionalItemsType +} + +/** + * Structured check for conditional types: `{ left: NodeType, right: NodeType }`. + */ +@Serializable +data class ConditionalCheck( + val left: NodeType, + val right: NodeType, +) + +/** + * Structured value for conditional types: `{ true: NodeType, false: NodeType }`. + */ +@Serializable +data class ConditionalValue( + @SerialName("true") val trueValue: NodeType, + @SerialName("false") val falseValue: NodeType, +) + +/** + * Base sealed interface for all XLR node types. + */ +sealed interface NodeType : Annotated { + val type: String + val source: String? + val genericTokens: List? +} + +@Serializable +@SerialName("string") +data class StringType( + override val type: String = "string", + val const: String? = null, + val enum: List? = null, + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("number") +data class NumberType( + override val type: String = "number", + val const: Double? = null, + val enum: List? = null, + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("boolean") +data class BooleanType( + override val type: String = "boolean", + val const: Boolean? = null, + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("null") +data class NullType( + override val type: String = "null", + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("any") +data class AnyType( + override val type: String = "any", + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("unknown") +data class UnknownType( + override val type: String = "unknown", + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("undefined") +data class UndefinedType( + override val type: String = "undefined", + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("void") +data class VoidType( + override val type: String = "void", + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("never") +data class NeverType( + override val type: String = "never", + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("ref") +data class RefType( + override val type: String = "ref", + val ref: String, + val genericArguments: List? = null, + val property: String? = null, + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +data class ObjectProperty( + val required: Boolean, + val node: NodeType, +) + +@Serializable +@SerialName("object") +data class ObjectType( + override val type: String = "object", + val properties: Map = emptyMap(), + val extends: RefType? = null, + val additionalProperties: AdditionalItemsType = AdditionalItemsType.None, + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("array") +data class ArrayType( + override val type: String = "array", + val elementType: NodeType, + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +data class TupleMember( + val name: String? = null, + val type: NodeType, + val optional: Boolean? = null, +) + +@Serializable +@SerialName("tuple") +data class TupleType( + override val type: String = "tuple", + val elementTypes: List, + val minItems: Int, + val additionalItems: AdditionalItemsType = AdditionalItemsType.None, + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("record") +data class RecordType( + override val type: String = "record", + val keyType: NodeType, + val valueType: NodeType, + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("or") +data class OrType( + override val type: String = "or", + @SerialName("or") + val orTypes: List, + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("and") +data class AndType( + override val type: String = "and", + @SerialName("and") + val andTypes: List, + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("template") +data class TemplateLiteralType( + override val type: String = "template", + val format: String, + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +@SerialName("conditional") +data class ConditionalType( + override val type: String = "conditional", + val check: ConditionalCheck, + val value: ConditionalValue, + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +data class FunctionParameter( + val name: String, + val type: NodeType, + val optional: Boolean? = null, + val default: NodeType? = null, +) + +@Serializable +@SerialName("function") +data class FunctionType( + override val type: String = "function", + val parameters: List, + val returnType: NodeType? = null, + override val name: String? = null, + override val title: String? = null, + override val description: String? = null, + override val examples: JsonElement? = null, + override val default: JsonElement? = null, + override val see: JsonElement? = null, + override val comment: String? = null, + override val meta: Map? = null, + override val source: String? = null, + override val genericTokens: List? = null, +) : NodeType + +@Serializable +data class ParamTypeNode( + val symbol: String, + val constraints: NodeType? = null, + val default: NodeType? = null, +) + +/** + * Named type wrapper that adds name and source information to any node type. + */ +@Serializable +data class NamedType( + @SerialName("name") val typeName: String, + val source: String, + val genericTokens: List? = null, + val node: T, +) + +/** + * Named type wrapper with required generic tokens. + */ +@Serializable +data class NamedTypeWithGenerics( + @SerialName("name") val typeName: String, + val source: String, + val genericTokens: List, + val node: T, +) + +/** + * Node type with generic tokens attached. + */ +@Serializable +data class NodeTypeWithGenerics( + val node: T, + val genericTokens: List, +) + +/** + * Complete XLR document representing a named object type (asset definition). + */ +@Serializable +data class XlrDocument( + val name: String, + val source: String, + val objectType: ObjectType, + val genericTokens: List? = null, +) { + fun toObjectType(): ObjectType = objectType +} + +/** + * Polymorphic serializers module for NodeType hierarchy. + */ +val xlrSerializersModule: SerializersModule = + SerializersModule { + polymorphic(NodeType::class) { + subclass(StringType::class) + subclass(NumberType::class) + subclass(BooleanType::class) + subclass(NullType::class) + subclass(AnyType::class) + subclass(UnknownType::class) + subclass(UndefinedType::class) + subclass(VoidType::class) + subclass(NeverType::class) + subclass(RefType::class) + subclass(ObjectType::class) + subclass(ArrayType::class) + subclass(TupleType::class) + subclass(RecordType::class) + subclass(OrType::class) + subclass(AndType::class) + subclass(TemplateLiteralType::class) + subclass(ConditionalType::class) + subclass(FunctionType::class) + } + } diff --git a/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrUtility.kt b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrUtility.kt new file mode 100644 index 0000000..dddf285 --- /dev/null +++ b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrUtility.kt @@ -0,0 +1,49 @@ +package com.intuit.playerui.xlr + +import kotlinx.serialization.Serializable + +@Serializable +data class Capability( + val name: String, + val provides: List, +) + +@Serializable +data class Manifest( + val pluginName: String, + val capabilities: Map>? = null, + val customPrimitives: List? = null, +) + +@Serializable +data class TSManifest( + val pluginName: String, + val capabilities: Map>>, +) + +sealed interface TransformInput { + data class Named( + val namedType: NamedType, + ) : TransformInput + + data class Anonymous( + val nodeType: NodeType, + ) : TransformInput +} + +sealed interface TransformOutput { + data class Named( + val namedType: NamedType, + ) : TransformOutput + + data class Anonymous( + val nodeType: NodeType, + ) : TransformOutput +} + +fun interface TransformFunction { + fun transform( + input: TransformInput, + capabilityType: String, + ): TransformOutput +} diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/AllTests.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/AllTests.kt new file mode 100644 index 0000000..688b844 --- /dev/null +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/AllTests.kt @@ -0,0 +1,13 @@ +package com.intuit.playerui.xlr + +import org.junit.runner.RunWith +import org.junit.runners.Suite + +@RunWith(Suite::class) +@Suite.SuiteClasses( + XlrDeserializerTest::class, + XlrGuardsTest::class, + XlrSerializerTest::class, + XlrTypesTest::class, +) +class AllTests diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrDeserializerTest.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrDeserializerTest.kt new file mode 100644 index 0000000..1a1a568 --- /dev/null +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrDeserializerTest.kt @@ -0,0 +1,567 @@ +package com.intuit.playerui.xlr + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class XlrDeserializerTest { + private val fixtureJson: String by lazy { + this::class.java + .getResourceAsStream("/test.json")!! + .bufferedReader() + .readText() + } + + @Test + fun `deserializes document name and source`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + assertEquals("ChoiceAsset", doc.name) + assertTrue(doc.source.contains("choice/types.ts")) + } + + @Test + fun `deserializes document objectType as object`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + assertEquals("object", doc.objectType.type) + } + + @Test + fun `toObjectType returns equivalent ObjectType`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val objType = doc.toObjectType() + assertEquals(doc.objectType, objType) + } + + @Test + fun `deserializes properties map with correct keys`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val props = doc.objectType.properties + assertTrue(props.containsKey("title")) + assertTrue(props.containsKey("note")) + assertTrue(props.containsKey("binding")) + assertTrue(props.containsKey("items")) + assertTrue(props.containsKey("metaData")) + assertEquals(5, props.size) + } + + @Test + fun `deserializes title property as RefType`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val titleProp = doc.objectType.properties["title"]!! + assertEquals(false, titleProp.required) + val node = assertIs(titleProp.node) + assertEquals("AssetWrapper", node.ref) + assertNotNull(node.genericArguments) + assertEquals(1, node.genericArguments!!.size) + val ga = assertIs(node.genericArguments!!.first()) + assertEquals("AnyTextAsset", ga.ref) + } + + @Test + fun `deserializes binding property as RefType`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val bindingNode = assertIs(doc.objectType.properties["binding"]!!.node) + assertEquals("Binding", bindingNode.ref) + assertEquals("ChoiceAsset.binding", bindingNode.title) + assertEquals("The location in the data-model to store the data", bindingNode.description) + } + + @Test + fun `deserializes items property as ArrayType with nested ObjectType`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val itemsNode = assertIs(doc.objectType.properties["items"]!!.node) + val elementType = assertIs(itemsNode.elementType) + assertTrue(elementType.properties.containsKey("id")) + assertTrue(elementType.properties.containsKey("label")) + assertTrue(elementType.properties.containsKey("value")) + } + + @Test + fun `deserializes nested ChoiceItem id as required StringType`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val itemsNode = assertIs(doc.objectType.properties["items"]!!.node) + val choiceItem = assertIs(itemsNode.elementType) + val idProp = choiceItem.properties["id"]!! + assertEquals(true, idProp.required) + assertIs(idProp.node) + } + + @Test + fun `deserializes nested ValueType as OrType with 4 members`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val itemsNode = assertIs(doc.objectType.properties["items"]!!.node) + val choiceItem = assertIs(itemsNode.elementType) + val valueNode = assertIs(choiceItem.properties["value"]!!.node) + assertEquals(4, valueNode.orTypes.size) + assertIs(valueNode.orTypes[0]) + assertIs(valueNode.orTypes[1]) + assertIs(valueNode.orTypes[2]) + assertIs(valueNode.orTypes[3]) + } + + @Test + fun `deserializes BeaconDataType as OrType with RecordType`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val metaDataNode = assertIs(doc.objectType.properties["metaData"]!!.node) + val beaconNode = assertIs(metaDataNode.properties["beacon"]!!.node) + assertEquals(2, beaconNode.orTypes.size) + assertIs(beaconNode.orTypes[0]) + val record = assertIs(beaconNode.orTypes[1]) + assertIs(record.keyType) + assertIs(record.valueType) + } + + @Test + fun `deserializes generic tokens on document`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + assertNotNull(doc.genericTokens) + assertEquals(1, doc.genericTokens!!.size) + val token = doc.genericTokens!!.first() + assertEquals("AnyTextAsset", token.symbol) + val constraint = assertIs(token.constraints) + assertEquals("Asset", constraint.ref) + val default = assertIs(token.default) + assertEquals("Asset", default.ref) + } + + @Test + fun `deserializes generic tokens on nested ChoiceItem`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val itemsNode = assertIs(doc.objectType.properties["items"]!!.node) + val choiceItem = assertIs(itemsNode.elementType) + assertNotNull(choiceItem.genericTokens) + assertEquals(1, choiceItem.genericTokens!!.size) + val token = choiceItem.genericTokens!!.first() + assertEquals("AnyTextAsset", token.symbol) + val constraint = assertIs(token.constraints) + assertEquals("Asset", constraint.ref) + val default = assertIs(token.default) + assertEquals("Asset", default.ref) + } + + @Test + fun `deserializes source on nested ChoiceItem`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val itemsNode = assertIs(doc.objectType.properties["items"]!!.node) + val choiceItem = assertIs(itemsNode.elementType) + assertNotNull(choiceItem.source) + assertTrue(choiceItem.source!!.contains("choice/types.ts")) + } + + @Test + fun `deserializes source on nested ValueType`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val itemsNode = assertIs(doc.objectType.properties["items"]!!.node) + val choiceItem = assertIs(itemsNode.elementType) + val valueNode = assertIs(choiceItem.properties["value"]!!.node) + assertNotNull(valueNode.source) + assertTrue(valueNode.source!!.contains("choice/types.ts")) + } + + @Test + fun `deserializes source on nested BeaconMetaData`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val metaDataNode = assertIs(doc.objectType.properties["metaData"]!!.node) + assertNotNull(metaDataNode.source) + assertTrue(metaDataNode.source!!.contains("beacon")) + } + + @Test + fun `deserializes source on nested BeaconDataType`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val metaDataNode = assertIs(doc.objectType.properties["metaData"]!!.node) + val beaconNode = assertIs(metaDataNode.properties["beacon"]!!.node) + assertNotNull(beaconNode.source) + assertTrue(beaconNode.source!!.contains("beacon")) + } + + @Test + fun `root objectType carries source and genericTokens`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + assertNotNull(doc.objectType.source) + assertTrue(doc.objectType.source!!.contains("choice/types.ts")) + assertNotNull(doc.objectType.genericTokens) + assertEquals(1, doc.objectType.genericTokens!!.size) + assertEquals( + "AnyTextAsset", + doc.objectType.genericTokens!! + .first() + .symbol, + ) + } + + @Test + fun `nodes without source or genericTokens default to null`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val bindingNode = assertIs(doc.objectType.properties["binding"]!!.node) + assertNull(bindingNode.source) + assertNull(bindingNode.genericTokens) + } + + @Test + fun `deserializes extends clause with Asset choice`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val ext = doc.objectType.extends + assertNotNull(ext) + assertEquals("Asset<\"choice\">", ext.ref) + assertNotNull(ext.genericArguments) + assertEquals(1, ext.genericArguments!!.size) + val arg = assertIs(ext.genericArguments!!.first()) + assertEquals("choice", arg.const) + } + + @Test + fun `deserializes additionalProperties as None for false`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + assertEquals(AdditionalItemsType.None, doc.objectType.additionalProperties) + } + + @Test + fun `deserializes annotation title on document`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + assertEquals("ChoiceAsset", doc.objectType.title) + } + + @Test + fun `deserializes annotation description on document`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + assertNotNull(doc.objectType.description) + assertTrue(doc.objectType.description!!.startsWith("A choice asset")) + } + + @Test + fun `deserializes annotation title on nested nodes`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val titleNode = assertIs(doc.objectType.properties["title"]!!.node) + assertEquals("ChoiceAsset.title", titleNode.title) + } + + @Test + fun `parseNode throws for missing type field`() { + val json = + kotlinx.serialization.json.Json + .parseToJsonElement("""{"ref": "foo"}""") + assertFailsWith { + XlrDeserializer.parseNode(json) + } + } + + @Test + fun `parseNode throws for unknown type`() { + val json = + kotlinx.serialization.json.Json + .parseToJsonElement("""{"type": "foobar"}""") + assertFailsWith { + XlrDeserializer.parseNode(json) + } + } + + @Test + fun `parseNode handles JsonNull as NullType`() { + val node = XlrDeserializer.parseNode(kotlinx.serialization.json.JsonNull) + assertIs(node) + } + + @Test + fun `parseNode deserializes simple string type`() { + val json = + kotlinx.serialization.json.Json + .parseToJsonElement("""{"type": "string", "const": "hello"}""") + val node = assertIs(XlrDeserializer.parseNode(json)) + assertEquals("hello", node.const) + } + + @Test + fun `parseNode deserializes string type with enum`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """{"type": "string", "enum": ["a", "b", "c"]}""", + ) + val node = assertIs(XlrDeserializer.parseNode(json)) + assertEquals(listOf("a", "b", "c"), node.enum) + } + + @Test + fun `parseNode deserializes number type with enum`() { + val json = + kotlinx.serialization.json.Json + .parseToJsonElement("""{"type": "number", "enum": [1.0, 2.0, 3.0]}""") + val node = assertIs(XlrDeserializer.parseNode(json)) + assertEquals(listOf(1.0, 2.0, 3.0), node.enum) + } + + @Test + fun `parseNode deserializes boolean type with const`() { + val json = + kotlinx.serialization.json.Json + .parseToJsonElement("""{"type": "boolean", "const": true}""") + val node = assertIs(XlrDeserializer.parseNode(json)) + assertEquals(true, node.const) + } + + @Test + fun `parseNode deserializes template literal type`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """{"type": "template", "format": "hello_\\d+"}""", + ) + val node = assertIs(XlrDeserializer.parseNode(json)) + assertEquals("hello_\\d+", node.format) + } + + @Test + fun `parseNode deserializes conditional type with structured check and value`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """ + { + "type": "conditional", + "check": {"left": {"type": "string"}, "right": {"type": "number"}}, + "value": {"true": {"type": "boolean"}, "false": {"type": "null"}} + } + """.trimIndent(), + ) + val node = assertIs(XlrDeserializer.parseNode(json)) + assertIs(node.check.left) + assertIs(node.check.right) + assertIs(node.value.trueValue) + assertIs(node.value.falseValue) + } + + @Test + fun `parseNode deserializes function type`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """ + { + "type": "function", + "parameters": [ + {"name": "x", "type": {"type": "string"}}, + {"name": "y", "type": {"type": "number"}, "optional": true} + ], + "returnType": {"type": "boolean"} + } + """.trimIndent(), + ) + val node = assertIs(XlrDeserializer.parseNode(json)) + assertEquals(2, node.parameters.size) + assertEquals("x", node.parameters[0].name) + assertIs(node.parameters[0].type) + assertNull(node.parameters[0].optional) + assertEquals("y", node.parameters[1].name) + assertEquals(true, node.parameters[1].optional) + assertIs(node.returnType) + } + + @Test + fun `parseNode deserializes function type with parameter default`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """ + { + "type": "function", + "parameters": [ + {"name": "x", "type": {"type": "string"}, "default": {"type": "string", "const": "hi"}} + ] + } + """.trimIndent(), + ) + val node = assertIs(XlrDeserializer.parseNode(json)) + assertEquals(1, node.parameters.size) + val defaultNode = assertIs(node.parameters[0].default) + assertEquals("hi", defaultNode.const) + } + + @Test + fun `parseNode deserializes ref type with property`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """{"type": "ref", "ref": "Foo", "property": "bar"}""", + ) + val node = assertIs(XlrDeserializer.parseNode(json)) + assertEquals("Foo", node.ref) + assertEquals("bar", node.property) + } + + @Test + fun `parseNode deserializes tuple type`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """ + { + "type": "tuple", + "elementTypes": [ + {"name": "first", "type": {"type": "string"}}, + {"name": "second", "type": {"type": "number"}, "optional": true} + ], + "minItems": 1, + "additionalItems": false + } + """.trimIndent(), + ) + val node = assertIs(XlrDeserializer.parseNode(json)) + assertEquals(2, node.elementTypes.size) + assertEquals("first", node.elementTypes[0].name) + assertIs(node.elementTypes[0].type) + assertEquals(true, node.elementTypes[1].optional) + assertEquals(1, node.minItems) + assertEquals(AdditionalItemsType.None, node.additionalItems) + } + + @Test + fun `parseNode deserializes tuple member without name`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """ + { + "type": "tuple", + "elementTypes": [{"type": {"type": "string"}}], + "minItems": 1 + } + """.trimIndent(), + ) + val node = assertIs(XlrDeserializer.parseNode(json)) + assertEquals(1, node.elementTypes.size) + assertNull(node.elementTypes[0].name) + assertIs(node.elementTypes[0].type) + } + + @Test + fun `parseNode deserializes and type`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """{"type": "and", "and": [{"type": "string"}, {"type": "number"}]}""", + ) + val node = assertIs(XlrDeserializer.parseNode(json)) + assertEquals(2, node.andTypes.size) + assertIs(node.andTypes[0]) + assertIs(node.andTypes[1]) + } + + @Test + fun `parseNode deserializes all simple types`() { + val simpleTypes = listOf("any", "unknown", "undefined", "void", "never", "null") + for (typeName in simpleTypes) { + val json = + kotlinx.serialization.json.Json + .parseToJsonElement("""{"type": "$typeName"}""") + val node = XlrDeserializer.parseNode(json) + assertEquals(typeName, node.type, "Failed for type: $typeName") + } + } + + @Test + fun `additionalProperties Typed parses NodeType`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """ + { + "type": "object", + "properties": {}, + "additionalProperties": {"type": "string"} + } + """.trimIndent(), + ) + val node = assertIs(XlrDeserializer.parseNode(json)) + val ap = assertIs(node.additionalProperties) + assertIs(ap.node) + } + + @Test + fun `additionalProperties null becomes None`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """{"type": "object", "properties": {}}""", + ) + val node = assertIs(XlrDeserializer.parseNode(json)) + assertEquals(AdditionalItemsType.None, node.additionalProperties) + } + + @Test + fun `parseNode preserves source and genericTokens on node`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """ + { + "type": "object", + "properties": {}, + "source": "foo.ts", + "name": "Foo", + "genericTokens": [ + {"symbol": "T", "constraints": {"type": "ref", "ref": "Bar"}} + ] + } + """.trimIndent(), + ) + val node = assertIs(XlrDeserializer.parseNode(json)) + assertEquals("foo.ts", node.source) + assertEquals("Foo", node.name) + assertNotNull(node.genericTokens) + assertEquals(1, node.genericTokens!!.size) + assertEquals("T", node.genericTokens!!.first().symbol) + } + + @Test + fun `throws on RefType missing ref`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """{"type": "ref"}""", + ) + assertFailsWith { + XlrDeserializer.parseNode(json) + } + } + + @Test + fun `throws on TemplateLiteralType missing format`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """{"type": "template"}""", + ) + assertFailsWith { + XlrDeserializer.parseNode(json) + } + } + + @Test + fun `throws on ArrayType missing elementType`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """{"type": "array"}""", + ) + assertFailsWith { + XlrDeserializer.parseNode(json) + } + } + + @Test + fun `throws on RecordType missing keyType`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """{"type": "record", "valueType": {"type": "any"}}""", + ) + assertFailsWith { + XlrDeserializer.parseNode(json) + } + } + + @Test + fun `throws on ConditionalType missing check`() { + val json = + kotlinx.serialization.json.Json.parseToJsonElement( + """ + { + "type": "conditional", + "value": {"true": {"type": "string"}, "false": {"type": "null"}} + } + """.trimIndent(), + ) + assertFailsWith { + XlrDeserializer.parseNode(json) + } + } +} diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrGuardsTest.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrGuardsTest.kt new file mode 100644 index 0000000..c59680c --- /dev/null +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrGuardsTest.kt @@ -0,0 +1,223 @@ +package com.intuit.playerui.xlr + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class XlrGuardsTest { + @Test + fun `isPrimitiveType returns true for all primitive types`() { + val primitives = + listOf( + StringType(), + NumberType(), + BooleanType(), + NullType(), + AnyType(), + UnknownType(), + UndefinedType(), + VoidType(), + NeverType(), + ) + for (node in primitives) { + assertTrue(isPrimitiveType(node), "Expected true for ${node::class.simpleName}") + } + } + + @Test + fun `isPrimitiveType returns false for non-primitive types`() { + val nonPrimitives: List = + listOf( + ObjectType(properties = emptyMap()), + ArrayType(elementType = StringType()), + RefType(ref = "Foo"), + OrType(orTypes = listOf(StringType())), + AndType(andTypes = listOf(StringType())), + RecordType(keyType = StringType(), valueType = AnyType()), + TupleType(elementTypes = emptyList(), minItems = 0), + TemplateLiteralType(format = ".*"), + ConditionalType( + check = ConditionalCheck(StringType(), NumberType()), + value = ConditionalValue(BooleanType(), NullType()), + ), + FunctionType(parameters = emptyList()), + ) + for (node in nonPrimitives) { + assertFalse(isPrimitiveType(node), "Expected false for ${node::class.simpleName}") + } + } + + @Test + fun `isAssetWrapperRef positive`() { + assertTrue(isAssetWrapperRef(RefType(ref = "AssetWrapper"))) + assertTrue(isAssetWrapperRef(RefType(ref = "AssetWrapper"))) + } + + @Test + fun `isAssetWrapperRef negative`() { + assertFalse(isAssetWrapperRef(RefType(ref = "Asset"))) + assertFalse(isAssetWrapperRef(StringType())) + } + + @Test + fun `isAssetRef positive`() { + assertTrue(isAssetRef(RefType(ref = "Asset<\"choice\">"))) + assertTrue(isAssetRef(RefType(ref = "Asset"))) + } + + @Test + fun `isAssetRef negative`() { + assertFalse(isAssetRef(RefType(ref = "AssetWrapper"))) + assertFalse(isAssetRef(StringType())) + } + + @Test + fun `isBindingRef positive`() { + assertTrue(isBindingRef(RefType(ref = "Binding"))) + assertTrue(isBindingRef(RefType(ref = "Binding"))) + } + + @Test + fun `isBindingRef negative`() { + assertFalse(isBindingRef(RefType(ref = "Expression"))) + assertFalse(isBindingRef(NumberType())) + } + + @Test + fun `isExpressionRef positive`() { + assertTrue(isExpressionRef(RefType(ref = "Expression"))) + assertTrue(isExpressionRef(RefType(ref = "Expression"))) + } + + @Test + fun `isExpressionRef negative`() { + assertFalse(isExpressionRef(RefType(ref = "Binding"))) + assertFalse(isExpressionRef(BooleanType())) + } + + @Test + fun `extractAssetTypeConstant extracts const from generic arg`() { + val ref = + RefType( + ref = "Asset<\"choice\">", + genericArguments = listOf(StringType(const = "choice")), + ) + assertEquals("choice", extractAssetTypeConstant(ref)) + } + + @Test + fun `extractAssetTypeConstant returns null for non-Asset ref`() { + val ref = RefType(ref = "Binding") + assertNull(extractAssetTypeConstant(ref)) + } + + @Test + fun `extractAssetTypeConstant returns null for null ref`() { + assertNull(extractAssetTypeConstant(null)) + } + + @Test + fun `extractAssetTypeConstant returns null for empty genericArguments`() { + val ref = RefType(ref = "Asset<\"choice\">", genericArguments = emptyList()) + assertNull(extractAssetTypeConstant(ref)) + } + + @Test + fun `extractAssetTypeConstant returns null for non-string generic arg`() { + val ref = + RefType( + ref = "Asset<\"choice\">", + genericArguments = listOf(NumberType()), + ) + assertNull(extractAssetTypeConstant(ref)) + } + + @Test + fun `hasConstValue returns true when const is set`() { + assertTrue(hasConstValue(StringType(const = "hello"))) + } + + @Test + fun `hasConstValue returns false when const is null`() { + assertFalse(hasConstValue(StringType())) + } + + @Test + fun `hasAnyConstValue returns true for string with const`() { + assertTrue(hasAnyConstValue(StringType(const = "x"))) + } + + @Test + fun `hasAnyConstValue returns true for number with const`() { + assertTrue(hasAnyConstValue(NumberType(const = 42.0))) + } + + @Test + fun `hasAnyConstValue returns true for boolean with const`() { + assertTrue(hasAnyConstValue(BooleanType(const = true))) + } + + @Test + fun `hasAnyConstValue returns false for types without const`() { + assertFalse(hasAnyConstValue(StringType())) + assertFalse(hasAnyConstValue(ObjectType(properties = emptyMap()))) + assertFalse(hasAnyConstValue(NullType())) + } + + @Test + fun `isLiteralUnion returns true when all members have const`() { + val union = + OrType( + orTypes = + listOf( + StringType(const = "a"), + StringType(const = "b"), + NumberType(const = 1.0), + ), + ) + assertTrue(isLiteralUnion(union)) + } + + @Test + fun `isLiteralUnion returns false when any member lacks const`() { + val union = + OrType( + orTypes = + listOf( + StringType(const = "a"), + StringType(), + ), + ) + assertFalse(isLiteralUnion(union)) + } + + @Test + fun `getLiteralValues extracts all const values`() { + val union = + OrType( + orTypes = + listOf( + StringType(const = "a"), + NumberType(const = 2.0), + BooleanType(const = false), + ), + ) + assertEquals(listOf("a", 2.0, false), getLiteralValues(union)) + } + + @Test + fun `getLiteralValues skips members without const`() { + val union = + OrType( + orTypes = + listOf( + StringType(const = "a"), + NullType(), + NumberType(const = 3.0), + ), + ) + assertEquals(listOf("a", 3.0), getLiteralValues(union)) + } +} diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrSerializerTest.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrSerializerTest.kt new file mode 100644 index 0000000..8aab62f --- /dev/null +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrSerializerTest.kt @@ -0,0 +1,458 @@ +package com.intuit.playerui.xlr + +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class XlrSerializerTest { + private val fixtureJson: String by lazy { + this::class.java + .getResourceAsStream("/test.json")!! + .bufferedReader() + .readText() + } + + @Test + fun `round-trip deserialize-serialize-deserialize produces equal documents`() { + val doc1 = XlrDeserializer.deserialize(fixtureJson) + val serialized = XlrSerializer.serialize(doc1) + val doc2 = XlrDeserializer.deserialize(serialized) + assertEquals(doc1, doc2) + } + + @Test + fun `serializes document name and source`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val jsonObj = XlrSerializer.serializeDocument(doc) + assertEquals("ChoiceAsset", jsonObj["name"]?.jsonPrimitive?.content) + assertTrue( + jsonObj["source"] + ?.jsonPrimitive + ?.content + ?.contains("choice/types.ts") == true, + ) + } + + @Test + fun `serializes document genericTokens`() { + val doc = XlrDeserializer.deserialize(fixtureJson) + val jsonObj = XlrSerializer.serializeDocument(doc) + val tokens = jsonObj["genericTokens"] + assertNotNull(tokens) + } + + @Test + fun `serializes StringType`() { + val node = StringType(const = "hello", name = "MyStr") + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("string", jsonObj["type"]?.jsonPrimitive?.content) + assertEquals("hello", jsonObj["const"]?.jsonPrimitive?.content) + assertEquals("MyStr", jsonObj["name"]?.jsonPrimitive?.content) + } + + @Test + fun `serializes StringType with enum`() { + val node = StringType(enum = listOf("a", "b")) + val jsonObj = XlrSerializer.serializeNode(node) + val arr = jsonObj["enum"]?.jsonArray + assertNotNull(arr) + assertEquals(2, arr.size) + assertEquals("a", arr[0].jsonPrimitive.content) + assertEquals("b", arr[1].jsonPrimitive.content) + } + + @Test + fun `serializes NumberType with enum`() { + val node = NumberType(enum = listOf(1.0, 2.0)) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("number", jsonObj["type"]?.jsonPrimitive?.content) + assertNotNull(jsonObj["enum"]) + } + + @Test + fun `serializes NumberType integer const without decimal`() { + val node = NumberType(const = 42.0) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("42", jsonObj["const"]?.jsonPrimitive?.content) + } + + @Test + fun `serializes NumberType fractional const with decimal`() { + val node = NumberType(const = 3.14) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("3.14", jsonObj["const"]?.jsonPrimitive?.content) + } + + @Test + fun `serializes BooleanType with const`() { + val node = BooleanType(const = true) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("boolean", jsonObj["type"]?.jsonPrimitive?.content) + assertEquals("true", jsonObj["const"]?.jsonPrimitive?.content) + } + + @Test + fun `serializes simple types`() { + assertEquals( + "null", + XlrSerializer.serializeNode(NullType())["type"]?.jsonPrimitive?.content, + ) + assertEquals( + "any", + XlrSerializer.serializeNode(AnyType())["type"]?.jsonPrimitive?.content, + ) + assertEquals( + "unknown", + XlrSerializer.serializeNode(UnknownType())["type"]?.jsonPrimitive?.content, + ) + assertEquals( + "undefined", + XlrSerializer.serializeNode(UndefinedType())["type"]?.jsonPrimitive?.content, + ) + assertEquals( + "void", + XlrSerializer.serializeNode(VoidType())["type"]?.jsonPrimitive?.content, + ) + assertEquals( + "never", + XlrSerializer.serializeNode(NeverType())["type"]?.jsonPrimitive?.content, + ) + } + + @Test + fun `serializes RefType with genericArguments`() { + val node = RefType(ref = "Foo", genericArguments = listOf(StringType())) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("ref", jsonObj["type"]?.jsonPrimitive?.content) + assertEquals("Foo", jsonObj["ref"]?.jsonPrimitive?.content) + assertNotNull(jsonObj["genericArguments"]) + } + + @Test + fun `serializes RefType with property`() { + val node = RefType(ref = "Foo", property = "bar") + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("bar", jsonObj["property"]?.jsonPrimitive?.content) + } + + @Test + fun `serializes ObjectType with properties and extends`() { + val node = + ObjectType( + properties = mapOf("x" to ObjectProperty(required = true, node = StringType())), + extends = RefType(ref = "Base"), + ) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("object", jsonObj["type"]?.jsonPrimitive?.content) + assertNotNull(jsonObj["properties"]) + assertNotNull(jsonObj["extends"]) + } + + @Test + fun `serializes ObjectType with empty properties`() { + val node = ObjectType() + val jsonObj = XlrSerializer.serializeNode(node) + assertTrue(jsonObj.containsKey("properties")) + assertEquals(0, jsonObj["properties"]?.jsonObject?.size) + } + + @Test + fun `serializes ObjectType with typed additionalProperties`() { + val node = + ObjectType( + additionalProperties = AdditionalItemsType.Typed(StringType()), + ) + val jsonObj = XlrSerializer.serializeNode(node) + val ap = jsonObj["additionalProperties"]?.jsonObject + assertNotNull(ap) + assertEquals("string", ap["type"]?.jsonPrimitive?.content) + } + + @Test + fun `serializes ArrayType`() { + val node = ArrayType(elementType = NumberType()) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("array", jsonObj["type"]?.jsonPrimitive?.content) + assertNotNull(jsonObj["elementType"]) + } + + @Test + fun `serializes TupleType`() { + val node = + TupleType( + elementTypes = listOf(TupleMember(name = "a", type = StringType())), + minItems = 1, + ) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("tuple", jsonObj["type"]?.jsonPrimitive?.content) + assertEquals("1", jsonObj["minItems"]?.jsonPrimitive?.content) + } + + @Test + fun `serializes TupleType with typed additionalItems`() { + val node = + TupleType( + elementTypes = listOf(TupleMember(type = StringType())), + minItems = 1, + additionalItems = AdditionalItemsType.Typed(AnyType()), + ) + val jsonObj = XlrSerializer.serializeNode(node) + val ai = jsonObj["additionalItems"]?.jsonObject + assertNotNull(ai) + assertEquals("any", ai["type"]?.jsonPrimitive?.content) + } + + @Test + fun `serializes TupleMember without name`() { + val node = + TupleType( + elementTypes = listOf(TupleMember(type = StringType())), + minItems = 1, + ) + val jsonObj = XlrSerializer.serializeNode(node) + val member = jsonObj["elementTypes"]?.jsonArray?.first()?.jsonObject + assertNotNull(member) + assertFalse(member.containsKey("name")) + } + + @Test + fun `serializes RecordType`() { + val node = RecordType(keyType = StringType(), valueType = AnyType()) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("record", jsonObj["type"]?.jsonPrimitive?.content) + assertNotNull(jsonObj["keyType"]) + assertNotNull(jsonObj["valueType"]) + } + + @Test + fun `serializes OrType`() { + val node = OrType(orTypes = listOf(StringType(), NumberType())) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("or", jsonObj["type"]?.jsonPrimitive?.content) + assertNotNull(jsonObj["or"]) + } + + @Test + fun `serializes AndType`() { + val node = AndType(andTypes = listOf(StringType(), NumberType())) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("and", jsonObj["type"]?.jsonPrimitive?.content) + assertNotNull(jsonObj["and"]) + } + + @Test + fun `serializes TemplateLiteralType`() { + val node = TemplateLiteralType(format = "hello_\\d+") + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("template", jsonObj["type"]?.jsonPrimitive?.content) + assertEquals("hello_\\d+", jsonObj["format"]?.jsonPrimitive?.content) + } + + @Test + fun `serializes ConditionalType`() { + val node = + ConditionalType( + check = ConditionalCheck(StringType(), NumberType()), + value = ConditionalValue(BooleanType(), NullType()), + ) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("conditional", jsonObj["type"]?.jsonPrimitive?.content) + assertNotNull(jsonObj["check"]) + assertNotNull(jsonObj["value"]) + } + + @Test + fun `serializes FunctionType`() { + val node = + FunctionType( + parameters = listOf(FunctionParameter(name = "x", type = StringType(), optional = true)), + returnType = BooleanType(), + ) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("function", jsonObj["type"]?.jsonPrimitive?.content) + assertNotNull(jsonObj["parameters"]) + assertNotNull(jsonObj["returnType"]) + } + + @Test + fun `serializes FunctionType with parameter default`() { + val node = + FunctionType( + parameters = + listOf( + FunctionParameter( + name = "x", + type = StringType(), + default = StringType(const = "hi"), + ), + ), + ) + val jsonObj = XlrSerializer.serializeNode(node) + val param = jsonObj["parameters"]?.jsonArray?.first()?.jsonObject + assertNotNull(param) + val defaultObj = param["default"]?.jsonObject + assertNotNull(defaultObj) + assertEquals("string", defaultObj["type"]?.jsonPrimitive?.content) + assertEquals("hi", defaultObj["const"]?.jsonPrimitive?.content) + } + + @Test + fun `null optional fields are not emitted`() { + val node = StringType() + val jsonObj = XlrSerializer.serializeNode(node) + assertFalse(jsonObj.containsKey("const")) + assertFalse(jsonObj.containsKey("enum")) + assertFalse(jsonObj.containsKey("name")) + assertFalse(jsonObj.containsKey("title")) + assertFalse(jsonObj.containsKey("description")) + assertFalse(jsonObj.containsKey("source")) + assertFalse(jsonObj.containsKey("genericTokens")) + assertFalse(jsonObj.containsKey("examples")) + assertFalse(jsonObj.containsKey("comment")) + assertFalse(jsonObj.containsKey("meta")) + } + + @Test + fun `serializes source and genericTokens on node`() { + val tokens = listOf(ParamTypeNode(symbol = "T", constraints = RefType(ref = "Base"))) + val node = ObjectType(source = "foo.ts", genericTokens = tokens, name = "Foo") + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("foo.ts", jsonObj["source"]?.jsonPrimitive?.content) + assertNotNull(jsonObj["genericTokens"]) + assertEquals("Foo", jsonObj["name"]?.jsonPrimitive?.content) + } + + @Test + fun `serializes annotations on nodes`() { + val node = + StringType( + title = "MyTitle", + description = "A string", + comment = "Some comment", + meta = mapOf("key" to "value"), + ) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals("MyTitle", jsonObj["title"]?.jsonPrimitive?.content) + assertEquals("A string", jsonObj["description"]?.jsonPrimitive?.content) + assertEquals("Some comment", jsonObj["comment"]?.jsonPrimitive?.content) + assertNotNull(jsonObj["meta"]) + assertEquals( + "value", + jsonObj["meta"] + ?.jsonObject + ?.get("key") + ?.jsonPrimitive + ?.content, + ) + } + + @Test + fun `round-trip ObjectType with empty properties`() { + val original = ObjectType() + val json = XlrSerializer.serializeNode(original) + val deserialized = XlrDeserializer.parseNode(json) + assertEquals(original, deserialized) + } + + @Test + fun `round-trip ObjectType with typed additionalProperties`() { + val original = + ObjectType( + additionalProperties = AdditionalItemsType.Typed(StringType()), + ) + val json = XlrSerializer.serializeNode(original) + val deserialized = XlrDeserializer.parseNode(json) + assertEquals(original, deserialized) + } + + @Test + fun `round-trip TupleType with typed additionalItems`() { + val original = + TupleType( + elementTypes = + listOf( + TupleMember(name = "a", type = StringType()), + TupleMember(type = NumberType(), optional = true), + ), + minItems = 1, + additionalItems = AdditionalItemsType.Typed(AnyType()), + ) + val json = XlrSerializer.serializeNode(original) + val deserialized = XlrDeserializer.parseNode(json) + assertEquals(original, deserialized) + } + + @Test + fun `round-trip nested ConditionalType`() { + val original = + ConditionalType( + check = + ConditionalCheck( + left = OrType(orTypes = listOf(StringType(), NumberType())), + right = RefType(ref = "Foo"), + ), + value = + ConditionalValue( + trueValue = ArrayType(elementType = BooleanType()), + falseValue = NullType(), + ), + ) + val json = XlrSerializer.serializeNode(original) + val deserialized = XlrDeserializer.parseNode(json) + assertEquals(original, deserialized) + } + + @Test + fun `round-trip FunctionType with parameter defaults`() { + val original = + FunctionType( + parameters = + listOf( + FunctionParameter( + name = "x", + type = StringType(), + default = StringType(const = "hi"), + ), + FunctionParameter( + name = "y", + type = NumberType(), + optional = true, + ), + ), + returnType = BooleanType(), + ) + val json = XlrSerializer.serializeNode(original) + val deserialized = XlrDeserializer.parseNode(json) + assertEquals(original, deserialized) + } + + @Test + fun `round-trip NumberType integer const preserves value`() { + val original = NumberType(const = 42.0) + val json = XlrSerializer.serializeNode(original) + val deserialized = assertIs(XlrDeserializer.parseNode(json)) + assertEquals(42.0, deserialized.const) + } + + @Test + fun `round-trip RefType with property`() { + val original = RefType(ref = "Foo", property = "bar") + val json = XlrSerializer.serializeNode(original) + val deserialized = XlrDeserializer.parseNode(json) + assertEquals(original, deserialized) + } + + @Test + fun `round-trip StringType with enum`() { + val original = StringType(enum = listOf("a", "b", "c")) + val json = XlrSerializer.serializeNode(original) + val deserialized = XlrDeserializer.parseNode(json) + assertEquals(original, deserialized) + } +} diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrTypesTest.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrTypesTest.kt new file mode 100644 index 0000000..c90ea88 --- /dev/null +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrTypesTest.kt @@ -0,0 +1,268 @@ +package com.intuit.playerui.xlr + +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class XlrTypesTest { + @Test + fun `StringType defaults`() { + val s = StringType() + assertEquals("string", s.type) + assertNull(s.const) + assertNull(s.enum) + assertNull(s.title) + assertNull(s.description) + assertNull(s.name) + assertNull(s.examples) + assertNull(s.default) + assertNull(s.see) + assertNull(s.comment) + assertNull(s.meta) + assertNull(s.source) + assertNull(s.genericTokens) + } + + @Test + fun `NumberType defaults`() { + val n = NumberType() + assertEquals("number", n.type) + assertNull(n.const) + assertNull(n.enum) + } + + @Test + fun `BooleanType defaults`() { + val b = BooleanType() + assertEquals("boolean", b.type) + assertNull(b.const) + } + + @Test + fun `all simple types have correct type field`() { + assertEquals("null", NullType().type) + assertEquals("any", AnyType().type) + assertEquals("unknown", UnknownType().type) + assertEquals("undefined", UndefinedType().type) + assertEquals("void", VoidType().type) + assertEquals("never", NeverType().type) + } + + @Test + fun `RefType construction`() { + val ref = RefType(ref = "Foo", genericArguments = listOf(StringType())) + assertEquals("ref", ref.type) + assertEquals("Foo", ref.ref) + assertEquals(1, ref.genericArguments?.size) + } + + @Test + fun `ObjectType defaults`() { + val obj = ObjectType() + assertEquals("object", obj.type) + assertEquals(emptyMap(), obj.properties) + assertNull(obj.extends) + assertEquals(AdditionalItemsType.None, obj.additionalProperties) + } + + @Test + fun `ObjectType with source and genericTokens`() { + val tokens = listOf(ParamTypeNode(symbol = "T", constraints = RefType(ref = "Asset"))) + val obj = ObjectType(source = "test.ts", genericTokens = tokens) + assertEquals("test.ts", obj.source) + assertNotNull(obj.genericTokens) + assertEquals(1, obj.genericTokens!!.size) + assertEquals("T", obj.genericTokens!!.first().symbol) + } + + @Test + fun `AdditionalItemsType None and Typed`() { + val none = AdditionalItemsType.None + val typed = AdditionalItemsType.Typed(StringType()) + assertIs(none) + assertIs(typed) + assertIs(typed.node) + } + + @Test + fun `ConditionalCheck construction`() { + val check = ConditionalCheck(left = StringType(), right = NumberType()) + assertIs(check.left) + assertIs(check.right) + } + + @Test + fun `ConditionalValue construction`() { + val value = ConditionalValue(trueValue = BooleanType(), falseValue = NullType()) + assertIs(value.trueValue) + assertIs(value.falseValue) + } + + @Test + fun `ConditionalType construction`() { + val ct = + ConditionalType( + check = ConditionalCheck(StringType(), NumberType()), + value = ConditionalValue(BooleanType(), NullType()), + ) + assertEquals("conditional", ct.type) + } + + @Test + fun `NamedType construction`() { + val nt = NamedType(typeName = "Foo", source = "bar.ts", node = StringType(const = "x")) + assertEquals("Foo", nt.typeName) + assertEquals("bar.ts", nt.source) + assertNull(nt.genericTokens) + assertIs(nt.node) + assertEquals("x", (nt.node as StringType).const) + } + + @Test + fun `NamedTypeWithGenerics construction`() { + val token = ParamTypeNode(symbol = "T", constraints = RefType(ref = "Asset")) + val ntg = + NamedTypeWithGenerics( + typeName = "Foo", + source = "bar.ts", + genericTokens = listOf(token), + node = ObjectType(), + ) + assertEquals("Foo", ntg.typeName) + assertEquals(1, ntg.genericTokens.size) + assertEquals("T", ntg.genericTokens.first().symbol) + } + + @Test + fun `NodeTypeWithGenerics construction`() { + val ntwg = + NodeTypeWithGenerics( + node = ArrayType(elementType = StringType()), + genericTokens = listOf(ParamTypeNode(symbol = "T")), + ) + assertIs(ntwg.node) + assertEquals(1, ntwg.genericTokens.size) + } + + @Test + fun `XlrDocument toObjectType delegates`() { + val objType = + ObjectType( + properties = mapOf("x" to ObjectProperty(required = true, node = StringType())), + title = "Test", + ) + val doc = XlrDocument(name = "Test", source = "test.ts", objectType = objType) + assertEquals(objType, doc.toObjectType()) + } + + @Test + fun `TupleType construction`() { + val tt = + TupleType( + elementTypes = + listOf( + TupleMember(name = "a", type = StringType()), + TupleMember(type = NumberType(), optional = true), + ), + minItems = 1, + additionalItems = AdditionalItemsType.Typed(AnyType()), + ) + assertEquals("tuple", tt.type) + assertEquals(2, tt.elementTypes.size) + assertEquals(1, tt.minItems) + assertIs(tt.additionalItems) + } + + @Test + fun `FunctionType construction`() { + val ft = + FunctionType( + parameters = + listOf( + FunctionParameter(name = "x", type = StringType(), optional = true), + ), + returnType = BooleanType(), + ) + assertEquals("function", ft.type) + assertEquals(1, ft.parameters.size) + } + + @Test + fun `xlrSerializersModule is a valid SerializersModule`() { + assertIs(xlrSerializersModule) + } + + @Test + fun `polymorphic serialization round-trip via Json`() { + val jsonInstance = + Json { + serializersModule = xlrSerializersModule + ignoreUnknownKeys = true + classDiscriminator = "_type" + } + val original = StringType(const = "test", title = "MyTitle") + val serialized = jsonInstance.encodeToString(kotlinx.serialization.serializer(), original) + val deserialized = jsonInstance.decodeFromString(kotlinx.serialization.serializer(), serialized) + assertEquals(original, deserialized) + } + + @Test + fun `NodeType sealed hierarchy includes all 19 types`() { + val instances: List = + listOf( + StringType(), + NumberType(), + BooleanType(), + NullType(), + AnyType(), + UnknownType(), + UndefinedType(), + VoidType(), + NeverType(), + RefType(ref = "Foo"), + ObjectType(), + ArrayType(elementType = StringType()), + TupleType(elementTypes = emptyList(), minItems = 0), + RecordType(keyType = StringType(), valueType = AnyType()), + OrType(orTypes = emptyList()), + AndType(andTypes = emptyList()), + TemplateLiteralType(format = ".*"), + ConditionalType( + check = ConditionalCheck(StringType(), NumberType()), + value = ConditionalValue(BooleanType(), NullType()), + ), + FunctionType(parameters = emptyList()), + ) + assertEquals(19, instances.size) + for (instance in instances) { + assertNotNull(instance.type) + } + } + + @Test + fun `Annotated interface is implemented by all NodeTypes`() { + val node: NodeType = StringType(title = "hello", comment = "world") + val annotated: Annotated = node + assertEquals("hello", annotated.title) + assertEquals("world", annotated.comment) + } + + @Test + fun `ParamTypeNode construction`() { + val param = ParamTypeNode(symbol = "T", constraints = RefType(ref = "Foo"), default = StringType()) + assertEquals("T", param.symbol) + assertIs(param.constraints) + assertIs(param.default) + } + + @Test + fun `ObjectProperty construction`() { + val prop = ObjectProperty(required = true, node = NumberType(const = 42.0)) + assertEquals(true, prop.required) + assertIs(prop.node) + } +} diff --git a/types/kotlin/src/test/resources/test.json b/types/kotlin/src/test/resources/test.json new file mode 100644 index 0000000..ff8e2bc --- /dev/null +++ b/types/kotlin/src/test/resources/test.json @@ -0,0 +1,191 @@ +{ + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/choice/types.ts", + "name": "ChoiceAsset", + "type": "object", + "properties": { + "title": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper", + "genericArguments": [ + { + "type": "ref", + "ref": "AnyTextAsset" + } + ], + "title": "ChoiceAsset.title", + "description": "A text-like asset for the choice's label" + } + }, + "note": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper", + "genericArguments": [ + { + "type": "ref", + "ref": "AnyTextAsset" + } + ], + "title": "ChoiceAsset.note", + "description": "Asset container for a note." + } + }, + "binding": { + "required": false, + "node": { + "type": "ref", + "ref": "Binding", + "title": "ChoiceAsset.binding", + "description": "The location in the data-model to store the data" + } + }, + "items": { + "required": false, + "node": { + "type": "array", + "elementType": { + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/choice/types.ts", + "name": "ChoiceItem", + "type": "object", + "properties": { + "id": { + "required": true, + "node": { + "type": "string", + "title": "ChoiceItem.id", + "description": "The id associated with the choice item" + } + }, + "label": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper", + "genericArguments": [ + { + "type": "ref", + "ref": "AnyTextAsset" + } + ], + "title": "ChoiceItem.label", + "description": "A text-like asset for the choice's label" + } + }, + "value": { + "required": false, + "node": { + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/choice/types.ts", + "name": "ValueType", + "type": "or", + "or": [ + { + "type": "string", + "title": "ValueType" + }, + { + "type": "number", + "title": "ValueType" + }, + { + "type": "boolean", + "title": "ValueType" + }, + { + "type": "null" + } + ], + "title": "ChoiceItem.value", + "description": "The value of the input from the data-model" + } + } + }, + "additionalProperties": false, + "title": "ChoiceItem", + "genericTokens": [ + { + "symbol": "AnyTextAsset", + "constraints": { + "type": "ref", + "ref": "Asset" + }, + "default": { + "type": "ref", + "ref": "Asset" + } + } + ] + }, + "title": "ChoiceAsset.items", + "description": "The options to select from" + } + }, + "metaData": { + "required": false, + "node": { + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/node_modules/.aspect_rules_js/@player-ui+beacon-plugin@0.0.0/node_modules/@player-ui/beacon-plugin/types/beacon.d.ts", + "name": "BeaconMetaData", + "type": "object", + "properties": { + "beacon": { + "required": false, + "node": { + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/node_modules/.aspect_rules_js/@player-ui+beacon-plugin@0.0.0/node_modules/@player-ui/beacon-plugin/types/beacon.d.ts", + "name": "BeaconDataType", + "type": "or", + "or": [ + { + "type": "string", + "title": "BeaconDataType" + }, + { + "type": "record", + "keyType": { + "type": "string" + }, + "valueType": { + "type": "any" + }, + "title": "BeaconDataType" + } + ], + "title": "BeaconMetaData.beacon", + "description": "Additional data to send along with beacons" + } + } + }, + "additionalProperties": false, + "title": "ChoiceAsset.metaData", + "description": "Optional additional data" + } + } + }, + "additionalProperties": false, + "title": "ChoiceAsset", + "description": "A choice asset represents a single selection choice, often displayed as radio buttons in a web context.\nThis will allow users to test out more complex flows than just inputs + buttons.", + "genericTokens": [ + { + "symbol": "AnyTextAsset", + "constraints": { + "type": "ref", + "ref": "Asset" + }, + "default": { + "type": "ref", + "ref": "Asset" + } + } + ], + "extends": { + "type": "ref", + "ref": "Asset<\"choice\">", + "genericArguments": [ + { + "type": "string", + "const": "choice" + } + ] + } +} \ No newline at end of file From 3a633b351a973c38c475dc8e393676246688cbcc Mon Sep 17 00:00:00 2001 From: Rafael Campos Date: Tue, 24 Feb 2026 15:26:07 -0500 Subject: [PATCH 2/5] refactor: serialization --- .bazelrc | 6 +- .../intuit/playerui/xlr/XlrDeserializer.kt | 512 +----------------- .../kotlin/com/intuit/playerui/xlr/XlrJson.kt | 10 + .../com/intuit/playerui/xlr/XlrSerializer.kt | 278 +--------- .../com/intuit/playerui/xlr/XlrTypes.kt | 146 +++-- .../com/intuit/playerui/xlr/TestFixtures.kt | 10 + .../playerui/xlr/XlrDeserializerTest.kt | 7 +- .../intuit/playerui/xlr/XlrSerializerTest.kt | 27 +- .../com/intuit/playerui/xlr/XlrTypesTest.kt | 16 +- 9 files changed, 168 insertions(+), 844 deletions(-) create mode 100644 types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrJson.kt create mode 100644 types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/TestFixtures.kt diff --git a/.bazelrc b/.bazelrc index d8fbf5b..418ea93 100644 --- a/.bazelrc +++ b/.bazelrc @@ -17,7 +17,11 @@ common --remote_cache_compression --remote_cache_async # Python config common --incompatible_default_to_explicit_init_py -# CI Config +# Java config - use remote JDK since CI doesn't have a local one +common --java_runtime_version=remotejdk_21 +common --tool_java_runtime_version=remotejdk_21 + +# CI Config common:ci --build_metadata=ROLE=CI common:ci --local_resources=cpu=4 common:ci --local_resources=memory=8000 diff --git a/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrDeserializer.kt b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrDeserializer.kt index 99ae411..9b2565f 100644 --- a/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrDeserializer.kt +++ b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrDeserializer.kt @@ -1,49 +1,25 @@ package com.intuit.playerui.xlr -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive object XlrDeserializer { - private val json = - Json { - ignoreUnknownKeys = true - isLenient = true - } - fun deserialize(jsonString: String): XlrDocument { - val element = json.parseToJsonElement(jsonString).jsonObject + val element = xlrJson.parseToJsonElement(jsonString).jsonObject return parseDocument(element) } fun parseDocument(obj: JsonObject): XlrDocument { - val annotations = parseAnnotations(obj) + val node = xlrJson.decodeFromJsonElement(NodeType.serializer(), obj) val objectType = - ObjectType( - properties = parseProperties(obj["properties"]), - extends = obj["extends"]?.let { parseRefType(it.jsonObject) }, - additionalProperties = parseAdditionalItems(obj["additionalProperties"]), - name = annotations.name, - title = annotations.title, - description = annotations.description, - examples = annotations.examples, - default = annotations.default, - see = annotations.see, - comment = annotations.comment, - meta = annotations.meta, - source = annotations.source, - genericTokens = annotations.genericTokens, - ) + node as? ObjectType + ?: throw IllegalArgumentException( + "Document root must be an object type, got: ${obj["type"]}", + ) return XlrDocument( name = obj["name"]?.jsonPrimitive?.content @@ -53,480 +29,14 @@ object XlrDeserializer { ?: throw IllegalArgumentException("Document missing 'source'"), objectType = objectType, genericTokens = - obj["genericTokens"]?.takeIf { it !is JsonNull }?.jsonArray?.map { - parseParamTypeNode(it.jsonObject) + obj["genericTokens"]?.takeIf { it !is JsonNull }?.let { + xlrJson.decodeFromJsonElement(it) }, ) } fun parseNode(element: JsonElement): NodeType { - if (element is JsonNull) { - return NullType() - } - - val obj = element.jsonObject - val type = - obj["type"]?.jsonPrimitive?.content - ?: throw IllegalArgumentException("Node missing 'type' field: $obj") - - return when (type) { - "string" -> parseStringType(obj) - "number" -> parseNumberType(obj) - "boolean" -> parseBooleanType(obj) - "null" -> parseNullType(obj) - "any" -> parseAnyType(obj) - "unknown" -> parseUnknownType(obj) - "undefined" -> parseUndefinedType(obj) - "void" -> parseVoidType(obj) - "never" -> parseNeverType(obj) - "ref" -> parseRefType(obj) - "object" -> parseObjectType(obj) - "array" -> parseArrayType(obj) - "tuple" -> parseTupleType(obj) - "record" -> parseRecordType(obj) - "or" -> parseOrType(obj) - "and" -> parseAndType(obj) - "template" -> parseTemplateLiteralType(obj) - "conditional" -> parseConditionalType(obj) - "function" -> parseFunctionType(obj) - else -> throw IllegalArgumentException("Unknown type: $type in object: $obj") - } - } - - private data class ParsedAnnotations( - val name: String? = null, - val title: String? = null, - val description: String? = null, - val examples: JsonElement? = null, - val default: JsonElement? = null, - val see: JsonElement? = null, - val comment: String? = null, - val meta: Map? = null, - val source: String? = null, - val genericTokens: List? = null, - ) - - private fun parseAnnotations(obj: JsonObject): ParsedAnnotations = - ParsedAnnotations( - name = obj["name"]?.jsonPrimitive?.contentOrNull, - title = obj["title"]?.jsonPrimitive?.contentOrNull, - description = obj["description"]?.jsonPrimitive?.contentOrNull, - examples = obj["examples"]?.takeIf { it !is JsonNull }, - default = obj["default"]?.takeIf { it !is JsonNull }, - see = obj["see"]?.takeIf { it !is JsonNull }, - comment = obj["comment"]?.jsonPrimitive?.contentOrNull, - meta = - obj["meta"]?.takeIf { it !is JsonNull }?.jsonObject?.mapValues { - it.value.jsonPrimitive.content - }, - source = obj["source"]?.jsonPrimitive?.contentOrNull, - genericTokens = - obj["genericTokens"]?.takeIf { it !is JsonNull }?.jsonArray?.map { - parseParamTypeNode(it.jsonObject) - }, - ) - - private fun parseAdditionalItems(element: JsonElement?): AdditionalItemsType { - if (element == null || element is JsonNull) return AdditionalItemsType.None - if (element is JsonPrimitive && element.booleanOrNull == false) return AdditionalItemsType.None - return AdditionalItemsType.Typed(parseNode(element)) - } - - private fun parseStringType(obj: JsonObject): StringType { - val a = parseAnnotations(obj) - return StringType( - const = obj["const"]?.jsonPrimitive?.contentOrNull, - enum = obj["enum"]?.jsonArray?.map { it.jsonPrimitive.content }, - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseNumberType(obj: JsonObject): NumberType { - val a = parseAnnotations(obj) - return NumberType( - const = obj["const"]?.jsonPrimitive?.doubleOrNull, - enum = - obj["enum"]?.jsonArray?.map { - it.jsonPrimitive.doubleOrNull - ?: throw IllegalArgumentException("Invalid number in enum: ${it.jsonPrimitive.content}") - }, - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseBooleanType(obj: JsonObject): BooleanType { - val a = parseAnnotations(obj) - return BooleanType( - const = obj["const"]?.jsonPrimitive?.booleanOrNull, - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseNullType(obj: JsonObject): NullType { - val a = parseAnnotations(obj) - return NullType( - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseAnyType(obj: JsonObject): AnyType { - val a = parseAnnotations(obj) - return AnyType( - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseUnknownType(obj: JsonObject): UnknownType { - val a = parseAnnotations(obj) - return UnknownType( - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) + if (element is JsonNull) return NullType() + return xlrJson.decodeFromJsonElement(NodeType.serializer(), element) } - - private fun parseUndefinedType(obj: JsonObject): UndefinedType { - val a = parseAnnotations(obj) - return UndefinedType( - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseVoidType(obj: JsonObject): VoidType { - val a = parseAnnotations(obj) - return VoidType( - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseNeverType(obj: JsonObject): NeverType { - val a = parseAnnotations(obj) - return NeverType( - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseRefType(obj: JsonObject): RefType { - val a = parseAnnotations(obj) - return RefType( - ref = - obj["ref"]?.jsonPrimitive?.content - ?: throw IllegalArgumentException("RefType missing 'ref': $obj"), - genericArguments = obj["genericArguments"]?.jsonArray?.map { parseNode(it) }, - property = obj["property"]?.jsonPrimitive?.contentOrNull, - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseObjectType(obj: JsonObject): ObjectType { - val a = parseAnnotations(obj) - return ObjectType( - properties = parseProperties(obj["properties"]), - extends = obj["extends"]?.let { parseRefType(it.jsonObject) }, - additionalProperties = parseAdditionalItems(obj["additionalProperties"]), - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseArrayType(obj: JsonObject): ArrayType { - val elementType = - obj["elementType"] - ?: throw IllegalArgumentException("Array missing 'elementType': $obj") - val a = parseAnnotations(obj) - return ArrayType( - elementType = parseNode(elementType), - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseTupleType(obj: JsonObject): TupleType { - val a = parseAnnotations(obj) - return TupleType( - elementTypes = - obj["elementTypes"]?.jsonArray?.map { parseTupleMember(it.jsonObject) } - ?: emptyList(), - minItems = obj["minItems"]?.jsonPrimitive?.int ?: 0, - additionalItems = parseAdditionalItems(obj["additionalItems"]), - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseTupleMember(obj: JsonObject): TupleMember = - TupleMember( - name = obj["name"]?.jsonPrimitive?.contentOrNull, - type = parseNode(obj["type"] ?: throw IllegalArgumentException("TupleMember missing 'type'")), - optional = obj["optional"]?.jsonPrimitive?.booleanOrNull, - ) - - private fun parseRecordType(obj: JsonObject): RecordType { - val a = parseAnnotations(obj) - return RecordType( - keyType = parseNode(obj["keyType"] ?: throw IllegalArgumentException("Record missing 'keyType'")), - valueType = parseNode(obj["valueType"] ?: throw IllegalArgumentException("Record missing 'valueType'")), - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseOrType(obj: JsonObject): OrType { - val a = parseAnnotations(obj) - return OrType( - orTypes = obj["or"]?.jsonArray?.map { parseNode(it) } ?: emptyList(), - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseAndType(obj: JsonObject): AndType { - val a = parseAnnotations(obj) - return AndType( - andTypes = obj["and"]?.jsonArray?.map { parseNode(it) } ?: emptyList(), - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseTemplateLiteralType(obj: JsonObject): TemplateLiteralType { - val a = parseAnnotations(obj) - return TemplateLiteralType( - format = - obj["format"]?.jsonPrimitive?.content - ?: throw IllegalArgumentException("TemplateLiteralType missing 'format': $obj"), - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseConditionalType(obj: JsonObject): ConditionalType { - val a = parseAnnotations(obj) - val checkObj = - obj["check"]?.jsonObject - ?: throw IllegalArgumentException("Conditional missing 'check': $obj") - val valueObj = - obj["value"]?.jsonObject - ?: throw IllegalArgumentException("Conditional missing 'value': $obj") - return ConditionalType( - check = - ConditionalCheck( - left = parseNode(checkObj["left"] ?: throw IllegalArgumentException("check missing 'left'")), - right = parseNode(checkObj["right"] ?: throw IllegalArgumentException("check missing 'right'")), - ), - value = - ConditionalValue( - trueValue = parseNode(valueObj["true"] ?: throw IllegalArgumentException("value missing 'true'")), - falseValue = - parseNode( - valueObj["false"] - ?: throw IllegalArgumentException("value missing 'false'"), - ), - ), - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseFunctionType(obj: JsonObject): FunctionType { - val a = parseAnnotations(obj) - return FunctionType( - parameters = - obj["parameters"]?.jsonArray?.map { parseFunctionParameter(it.jsonObject) } - ?: emptyList(), - returnType = obj["returnType"]?.let { parseNode(it) }, - name = a.name, - title = a.title, - description = a.description, - examples = a.examples, - default = a.default, - see = a.see, - comment = a.comment, - meta = a.meta, - source = a.source, - genericTokens = a.genericTokens, - ) - } - - private fun parseFunctionParameter(obj: JsonObject): FunctionParameter = - FunctionParameter( - name = - obj["name"]?.jsonPrimitive?.content - ?: throw IllegalArgumentException("FunctionParameter missing 'name': $obj"), - type = parseNode(obj["type"] ?: throw IllegalArgumentException("Parameter missing 'type'")), - optional = obj["optional"]?.jsonPrimitive?.booleanOrNull, - default = obj["default"]?.takeIf { it !is JsonNull }?.let { parseNode(it) }, - ) - - private fun parseProperties(element: JsonElement?): Map { - if (element == null || element is JsonNull) return emptyMap() - return element.jsonObject.mapValues { (_, propElement) -> - val propObj = propElement.jsonObject - ObjectProperty( - required = propObj["required"]?.jsonPrimitive?.boolean ?: false, - node = parseNode(propObj["node"] ?: throw IllegalArgumentException("Property missing 'node'")), - ) - } - } - - private fun parseParamTypeNode(obj: JsonObject): ParamTypeNode = - ParamTypeNode( - symbol = - obj["symbol"]?.jsonPrimitive?.content - ?: throw IllegalArgumentException("ParamTypeNode missing 'symbol': $obj"), - constraints = obj["constraints"]?.takeIf { it !is JsonNull }?.let { parseNode(it) }, - default = obj["default"]?.takeIf { it !is JsonNull }?.let { parseNode(it) }, - ) } diff --git a/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrJson.kt b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrJson.kt new file mode 100644 index 0000000..4ced1da --- /dev/null +++ b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrJson.kt @@ -0,0 +1,10 @@ +package com.intuit.playerui.xlr + +import kotlinx.serialization.json.Json + +internal val xlrJson = + Json { + encodeDefaults = false + ignoreUnknownKeys = true + isLenient = true + } diff --git a/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrSerializer.kt b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrSerializer.kt index 628be52..d3420fc 100644 --- a/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrSerializer.kt +++ b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrSerializer.kt @@ -1,285 +1,27 @@ package com.intuit.playerui.xlr -import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject object XlrSerializer { fun serialize(document: XlrDocument): String = serializeDocument(document).toString() + // Wire format requires ObjectType fields flattened at the document root level + // (not nested under an "objectType" key), so we manually merge the node JSON + // with document-level name/source/genericTokens which take precedence. fun serializeDocument(document: XlrDocument): JsonObject = buildJsonObject { - put("type", JsonPrimitive("object")) - - val objType = document.objectType - put("properties", serializeProperties(objType.properties)) - objType.extends?.let { put("extends", serializeNode(it)) } - putAdditionalItems("additionalProperties", objType.additionalProperties) - putAnnotations(objType) - - // Document-level fields take precedence over objectType annotations + val nodeJson = xlrJson.encodeToJsonElement(NodeType.serializer(), document.objectType).jsonObject + nodeJson.forEach { (key, value) -> put(key, value) } put("source", JsonPrimitive(document.source)) put("name", JsonPrimitive(document.name)) - document.genericTokens?.let { put("genericTokens", serializeParamTypeNodes(it)) } - } - - fun serializeNode(node: NodeType): JsonObject = - when (node) { - is StringType -> serializeStringType(node) - is NumberType -> serializeNumberType(node) - is BooleanType -> serializeBooleanType(node) - is NullType -> serializeNullType(node) - is AnyType -> serializeAnyType(node) - is UnknownType -> serializeUnknownType(node) - is UndefinedType -> serializeUndefinedType(node) - is VoidType -> serializeVoidType(node) - is NeverType -> serializeNeverType(node) - is RefType -> serializeRefType(node) - is ObjectType -> serializeObjectType(node) - is ArrayType -> serializeArrayType(node) - is TupleType -> serializeTupleType(node) - is RecordType -> serializeRecordType(node) - is OrType -> serializeOrType(node) - is AndType -> serializeAndType(node) - is TemplateLiteralType -> serializeTemplateLiteralType(node) - is ConditionalType -> serializeConditionalType(node) - is FunctionType -> serializeFunctionType(node) - } - - private fun kotlinx.serialization.json.JsonObjectBuilder.putAnnotations(node: NodeType) { - node.source?.let { put("source", JsonPrimitive(it)) } - node.name?.let { put("name", JsonPrimitive(it)) } - node.title?.let { put("title", JsonPrimitive(it)) } - node.description?.let { put("description", JsonPrimitive(it)) } - node.examples?.let { put("examples", it) } - node.default?.let { put("default", it) } - node.see?.let { put("see", it) } - node.comment?.let { put("comment", JsonPrimitive(it)) } - node.meta?.let { metaMap -> - put( - "meta", - buildJsonObject { - metaMap.forEach { (k, v) -> put(k, JsonPrimitive(v)) } - }, - ) - } - node.genericTokens?.let { put("genericTokens", serializeParamTypeNodes(it)) } - } - - private fun kotlinx.serialization.json.JsonObjectBuilder.putAdditionalItems( - key: String, - items: AdditionalItemsType, - ) { - when (items) { - is AdditionalItemsType.None -> put(key, JsonPrimitive(false)) - is AdditionalItemsType.Typed -> put(key, serializeNode(items.node)) - } - } - - private fun serializeStringType(node: StringType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("string")) - node.const?.let { put("const", JsonPrimitive(it)) } - node.enum?.let { put("enum", buildJsonArray { it.forEach { v -> add(JsonPrimitive(v)) } }) } - putAnnotations(node) - } - - private fun serializeNumberType(node: NumberType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("number")) - node.const?.let { put("const", doubleToJsonPrimitive(it)) } - node.enum?.let { put("enum", buildJsonArray { it.forEach { v -> add(doubleToJsonPrimitive(v)) } }) } - putAnnotations(node) - } - - private fun serializeBooleanType(node: BooleanType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("boolean")) - node.const?.let { put("const", JsonPrimitive(it)) } - putAnnotations(node) - } - - private fun serializeNullType(node: NullType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("null")) - putAnnotations(node) - } - - private fun serializeAnyType(node: AnyType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("any")) - putAnnotations(node) - } - - private fun serializeUnknownType(node: UnknownType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("unknown")) - putAnnotations(node) - } - - private fun serializeUndefinedType(node: UndefinedType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("undefined")) - putAnnotations(node) - } - - private fun serializeVoidType(node: VoidType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("void")) - putAnnotations(node) - } - - private fun serializeNeverType(node: NeverType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("never")) - putAnnotations(node) - } - - private fun serializeRefType(node: RefType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("ref")) - put("ref", JsonPrimitive(node.ref)) - node.genericArguments?.let { - put("genericArguments", buildJsonArray { it.forEach { n -> add(serializeNode(n)) } }) - } - node.property?.let { put("property", JsonPrimitive(it)) } - putAnnotations(node) - } - - private fun serializeObjectType(node: ObjectType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("object")) - put("properties", serializeProperties(node.properties)) - node.extends?.let { put("extends", serializeNode(it)) } - putAdditionalItems("additionalProperties", node.additionalProperties) - putAnnotations(node) - } - - private fun serializeArrayType(node: ArrayType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("array")) - put("elementType", serializeNode(node.elementType)) - putAnnotations(node) - } - - private fun serializeTupleType(node: TupleType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("tuple")) - put("elementTypes", buildJsonArray { node.elementTypes.forEach { add(serializeTupleMember(it)) } }) - put("minItems", JsonPrimitive(node.minItems)) - putAdditionalItems("additionalItems", node.additionalItems) - putAnnotations(node) - } - - private fun serializeRecordType(node: RecordType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("record")) - put("keyType", serializeNode(node.keyType)) - put("valueType", serializeNode(node.valueType)) - putAnnotations(node) - } - - private fun serializeOrType(node: OrType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("or")) - put("or", buildJsonArray { node.orTypes.forEach { add(serializeNode(it)) } }) - putAnnotations(node) - } - - private fun serializeAndType(node: AndType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("and")) - put("and", buildJsonArray { node.andTypes.forEach { add(serializeNode(it)) } }) - putAnnotations(node) - } - - private fun serializeTemplateLiteralType(node: TemplateLiteralType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("template")) - put("format", JsonPrimitive(node.format)) - putAnnotations(node) - } - - private fun serializeConditionalType(node: ConditionalType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("conditional")) - put( - "check", - buildJsonObject { - put("left", serializeNode(node.check.left)) - put("right", serializeNode(node.check.right)) - }, - ) - put( - "value", - buildJsonObject { - put("true", serializeNode(node.value.trueValue)) - put("false", serializeNode(node.value.falseValue)) - }, - ) - putAnnotations(node) - } - - private fun serializeFunctionType(node: FunctionType): JsonObject = - buildJsonObject { - put("type", JsonPrimitive("function")) - put( - "parameters", - buildJsonArray { - node.parameters.forEach { add(serializeFunctionParameter(it)) } - }, - ) - node.returnType?.let { put("returnType", serializeNode(it)) } - putAnnotations(node) - } - - private fun serializeFunctionParameter(param: FunctionParameter): JsonObject = - buildJsonObject { - put("name", JsonPrimitive(param.name)) - put("type", serializeNode(param.type)) - param.optional?.let { put("optional", JsonPrimitive(it)) } - param.default?.let { put("default", serializeNode(it)) } - } - - private fun serializeProperties(properties: Map): JsonObject = - buildJsonObject { - properties.forEach { (key, prop) -> - put(key, serializeObjectProperty(prop)) + document.genericTokens?.let { + put("genericTokens", xlrJson.encodeToJsonElement(it)) } } - private fun serializeObjectProperty(prop: ObjectProperty): JsonObject = - buildJsonObject { - put("required", JsonPrimitive(prop.required)) - put("node", serializeNode(prop.node)) - } - - private fun serializeTupleMember(member: TupleMember): JsonObject = - buildJsonObject { - member.name?.let { put("name", JsonPrimitive(it)) } - put("type", serializeNode(member.type)) - member.optional?.let { put("optional", JsonPrimitive(it)) } - } - - private fun doubleToJsonPrimitive(value: Double): JsonPrimitive = - if (value.isFinite() && value % 1.0 == 0.0) { - JsonPrimitive(value.toLong()) - } else { - JsonPrimitive(value) - } - - private fun serializeParamTypeNodes(tokens: List): JsonArray = - buildJsonArray { - tokens.forEach { token -> - add( - buildJsonObject { - put("symbol", JsonPrimitive(token.symbol)) - token.constraints?.let { put("constraints", serializeNode(it)) } - token.default?.let { put("default", serializeNode(it)) } - }, - ) - } - } + fun serializeNode(node: NodeType): JsonObject = xlrJson.encodeToJsonElement(NodeType.serializer(), node).jsonObject } diff --git a/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrTypes.kt b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrTypes.kt index fc6bfe0..5c0799f 100644 --- a/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrTypes.kt +++ b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrTypes.kt @@ -1,11 +1,30 @@ +@file:OptIn(ExperimentalSerializationApi::class) +@file:UseSerializers(WholeNumberDoubleSerializer::class) + package com.intuit.playerui.xlr +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.Transient +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonClassDiscriminator +import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.modules.SerializersModule -import kotlinx.serialization.modules.polymorphic -import kotlinx.serialization.modules.subclass +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement /** * Interface for annotation fields on XLR types. @@ -25,17 +44,45 @@ interface Annotated { /** * Sealed type representing `false | NodeType` for additional properties/items. */ -@Serializable +@Serializable(with = AdditionalItemsTypeSerializer::class) sealed interface AdditionalItemsType { - @Serializable data object None : AdditionalItemsType - @Serializable data class Typed( val node: NodeType, ) : AdditionalItemsType } +object AdditionalItemsTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("AdditionalItemsType", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: AdditionalItemsType) { + val jsonEncoder = + encoder as? JsonEncoder + ?: throw SerializationException("AdditionalItemsTypeSerializer only supports JSON format") + when (value) { + is AdditionalItemsType.None -> jsonEncoder.encodeJsonElement(JsonPrimitive(false)) + is AdditionalItemsType.Typed -> + jsonEncoder.encodeJsonElement( + jsonEncoder.json.encodeToJsonElement(NodeType.serializer(), value.node), + ) + } + } + + override fun deserialize(decoder: Decoder): AdditionalItemsType { + val jsonDecoder = + decoder as? JsonDecoder + ?: throw SerializationException("AdditionalItemsTypeSerializer only supports JSON format") + val element = jsonDecoder.decodeJsonElement() + if (element is JsonNull) return AdditionalItemsType.None + if (element is JsonPrimitive && element.booleanOrNull == false) return AdditionalItemsType.None + return AdditionalItemsType.Typed( + jsonDecoder.json.decodeFromJsonElement(NodeType.serializer(), element), + ) + } +} + /** * Structured check for conditional types: `{ left: NodeType, right: NodeType }`. */ @@ -57,6 +104,8 @@ data class ConditionalValue( /** * Base sealed interface for all XLR node types. */ +@Serializable +@JsonClassDiscriminator("type") sealed interface NodeType : Annotated { val type: String val source: String? @@ -66,7 +115,7 @@ sealed interface NodeType : Annotated { @Serializable @SerialName("string") data class StringType( - override val type: String = "string", + @Transient override val type: String = "string", val const: String? = null, val enum: List? = null, override val name: String? = null, @@ -84,7 +133,7 @@ data class StringType( @Serializable @SerialName("number") data class NumberType( - override val type: String = "number", + @Transient override val type: String = "number", val const: Double? = null, val enum: List? = null, override val name: String? = null, @@ -102,7 +151,7 @@ data class NumberType( @Serializable @SerialName("boolean") data class BooleanType( - override val type: String = "boolean", + @Transient override val type: String = "boolean", val const: Boolean? = null, override val name: String? = null, override val title: String? = null, @@ -119,7 +168,7 @@ data class BooleanType( @Serializable @SerialName("null") data class NullType( - override val type: String = "null", + @Transient override val type: String = "null", override val name: String? = null, override val title: String? = null, override val description: String? = null, @@ -135,7 +184,7 @@ data class NullType( @Serializable @SerialName("any") data class AnyType( - override val type: String = "any", + @Transient override val type: String = "any", override val name: String? = null, override val title: String? = null, override val description: String? = null, @@ -151,7 +200,7 @@ data class AnyType( @Serializable @SerialName("unknown") data class UnknownType( - override val type: String = "unknown", + @Transient override val type: String = "unknown", override val name: String? = null, override val title: String? = null, override val description: String? = null, @@ -167,7 +216,7 @@ data class UnknownType( @Serializable @SerialName("undefined") data class UndefinedType( - override val type: String = "undefined", + @Transient override val type: String = "undefined", override val name: String? = null, override val title: String? = null, override val description: String? = null, @@ -183,7 +232,7 @@ data class UndefinedType( @Serializable @SerialName("void") data class VoidType( - override val type: String = "void", + @Transient override val type: String = "void", override val name: String? = null, override val title: String? = null, override val description: String? = null, @@ -199,7 +248,7 @@ data class VoidType( @Serializable @SerialName("never") data class NeverType( - override val type: String = "never", + @Transient override val type: String = "never", override val name: String? = null, override val title: String? = null, override val description: String? = null, @@ -215,7 +264,7 @@ data class NeverType( @Serializable @SerialName("ref") data class RefType( - override val type: String = "ref", + @Transient override val type: String = "ref", val ref: String, val genericArguments: List? = null, val property: String? = null, @@ -240,9 +289,11 @@ data class ObjectProperty( @Serializable @SerialName("object") data class ObjectType( - override val type: String = "object", + @Transient override val type: String = "object", + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val properties: Map = emptyMap(), val extends: RefType? = null, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val additionalProperties: AdditionalItemsType = AdditionalItemsType.None, override val name: String? = null, override val title: String? = null, @@ -259,7 +310,7 @@ data class ObjectType( @Serializable @SerialName("array") data class ArrayType( - override val type: String = "array", + @Transient override val type: String = "array", val elementType: NodeType, override val name: String? = null, override val title: String? = null, @@ -283,9 +334,10 @@ data class TupleMember( @Serializable @SerialName("tuple") data class TupleType( - override val type: String = "tuple", + @Transient override val type: String = "tuple", val elementTypes: List, val minItems: Int, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val additionalItems: AdditionalItemsType = AdditionalItemsType.None, override val name: String? = null, override val title: String? = null, @@ -302,7 +354,7 @@ data class TupleType( @Serializable @SerialName("record") data class RecordType( - override val type: String = "record", + @Transient override val type: String = "record", val keyType: NodeType, val valueType: NodeType, override val name: String? = null, @@ -317,10 +369,12 @@ data class RecordType( override val genericTokens: List? = null, ) : NodeType +// Class-level @SerialName("or") sets the polymorphic discriminator value ("type": "or"), +// while property-level @SerialName("or") aliases the Kotlin field `orTypes` to JSON key "or". @Serializable @SerialName("or") data class OrType( - override val type: String = "or", + @Transient override val type: String = "or", @SerialName("or") val orTypes: List, override val name: String? = null, @@ -335,10 +389,12 @@ data class OrType( override val genericTokens: List? = null, ) : NodeType +// Class-level @SerialName("and") sets the polymorphic discriminator value ("type": "and"), +// while property-level @SerialName("and") aliases the Kotlin field `andTypes` to JSON key "and". @Serializable @SerialName("and") data class AndType( - override val type: String = "and", + @Transient override val type: String = "and", @SerialName("and") val andTypes: List, override val name: String? = null, @@ -356,7 +412,7 @@ data class AndType( @Serializable @SerialName("template") data class TemplateLiteralType( - override val type: String = "template", + @Transient override val type: String = "template", val format: String, override val name: String? = null, override val title: String? = null, @@ -373,7 +429,7 @@ data class TemplateLiteralType( @Serializable @SerialName("conditional") data class ConditionalType( - override val type: String = "conditional", + @Transient override val type: String = "conditional", val check: ConditionalCheck, val value: ConditionalValue, override val name: String? = null, @@ -399,7 +455,7 @@ data class FunctionParameter( @Serializable @SerialName("function") data class FunctionType( - override val type: String = "function", + @Transient override val type: String = "function", val parameters: List, val returnType: NodeType? = null, override val name: String? = null, @@ -465,30 +521,20 @@ data class XlrDocument( fun toObjectType(): ObjectType = objectType } -/** - * Polymorphic serializers module for NodeType hierarchy. - */ -val xlrSerializersModule: SerializersModule = - SerializersModule { - polymorphic(NodeType::class) { - subclass(StringType::class) - subclass(NumberType::class) - subclass(BooleanType::class) - subclass(NullType::class) - subclass(AnyType::class) - subclass(UnknownType::class) - subclass(UndefinedType::class) - subclass(VoidType::class) - subclass(NeverType::class) - subclass(RefType::class) - subclass(ObjectType::class) - subclass(ArrayType::class) - subclass(TupleType::class) - subclass(RecordType::class) - subclass(OrType::class) - subclass(AndType::class) - subclass(TemplateLiteralType::class) - subclass(ConditionalType::class) - subclass(FunctionType::class) +object WholeNumberDoubleSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("WholeNumberDouble", PrimitiveKind.DOUBLE) + + override fun serialize(encoder: Encoder, value: Double) { + val jsonEncoder = + encoder as? JsonEncoder + ?: throw SerializationException("WholeNumberDoubleSerializer only supports JSON format") + if (value.isFinite() && value % 1.0 == 0.0) { + jsonEncoder.encodeJsonElement(JsonPrimitive(value.toLong())) + } else { + jsonEncoder.encodeJsonElement(JsonPrimitive(value)) } } + + override fun deserialize(decoder: Decoder): Double = decoder.decodeDouble() +} diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/TestFixtures.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/TestFixtures.kt new file mode 100644 index 0000000..5fbabae --- /dev/null +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/TestFixtures.kt @@ -0,0 +1,10 @@ +package com.intuit.playerui.xlr + +object TestFixtures { + val choiceAssetJson: String by lazy { + TestFixtures::class.java + .getResourceAsStream("/test.json")!! + .bufferedReader() + .readText() + } +} diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrDeserializerTest.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrDeserializerTest.kt index 1a1a568..66c8757 100644 --- a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrDeserializerTest.kt +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrDeserializerTest.kt @@ -9,12 +9,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class XlrDeserializerTest { - private val fixtureJson: String by lazy { - this::class.java - .getResourceAsStream("/test.json")!! - .bufferedReader() - .readText() - } + private val fixtureJson: String get() = TestFixtures.choiceAssetJson @Test fun `deserializes document name and source`() { diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrSerializerTest.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrSerializerTest.kt index 8aab62f..f73b503 100644 --- a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrSerializerTest.kt +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrSerializerTest.kt @@ -1,5 +1,6 @@ package com.intuit.playerui.xlr +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -11,12 +12,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue class XlrSerializerTest { - private val fixtureJson: String by lazy { - this::class.java - .getResourceAsStream("/test.json")!! - .bufferedReader() - .readText() - } + private val fixtureJson: String get() = TestFixtures.choiceAssetJson @Test fun `round-trip deserialize-serialize-deserialize produces equal documents`() { @@ -455,4 +451,23 @@ class XlrSerializerTest { val deserialized = XlrDeserializer.parseNode(json) assertEquals(original, deserialized) } + + @Test + fun `AdditionalItemsType None serializes to false`() { + val obj = ObjectType(additionalProperties = AdditionalItemsType.None) + val json = XlrSerializer.serializeNode(obj) + assertEquals(JsonPrimitive(false), json["additionalProperties"]) + } + + @Test + fun `TupleType additionalItems None serializes to false`() { + val tuple = + TupleType( + elementTypes = listOf(TupleMember(type = StringType())), + minItems = 1, + additionalItems = AdditionalItemsType.None, + ) + val json = XlrSerializer.serializeNode(tuple) + assertEquals(JsonPrimitive(false), json["additionalItems"]) + } } diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrTypesTest.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrTypesTest.kt index c90ea88..7ef4bfe 100644 --- a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrTypesTest.kt +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrTypesTest.kt @@ -1,7 +1,6 @@ package com.intuit.playerui.xlr import kotlinx.serialization.json.Json -import kotlinx.serialization.modules.SerializersModule import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -192,21 +191,14 @@ class XlrTypesTest { } @Test - fun `xlrSerializersModule is a valid SerializersModule`() { - assertIs(xlrSerializersModule) - } - - @Test - fun `polymorphic serialization round-trip via Json`() { + fun `polymorphic serialization round-trip via NodeType serializer`() { val jsonInstance = Json { - serializersModule = xlrSerializersModule ignoreUnknownKeys = true - classDiscriminator = "_type" } - val original = StringType(const = "test", title = "MyTitle") - val serialized = jsonInstance.encodeToString(kotlinx.serialization.serializer(), original) - val deserialized = jsonInstance.decodeFromString(kotlinx.serialization.serializer(), serialized) + val original: NodeType = StringType(const = "test", title = "MyTitle") + val serialized = jsonInstance.encodeToString(NodeType.serializer(), original) + val deserialized = jsonInstance.decodeFromString(NodeType.serializer(), serialized) assertEquals(original, deserialized) } From e2f9cde6221ecb2c5d34ab40ab214324d173820a Mon Sep 17 00:00:00 2001 From: Rafael Campos Date: Tue, 24 Feb 2026 15:33:56 -0500 Subject: [PATCH 3/5] fix: ci --- MODULE.bazel | 2 +- MODULE.bazel.lock | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index 8c3a0ff..1b63f79 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -68,7 +68,7 @@ use_repo(pip, "pypi") ####### Kotlin ######### bazel_dep(name = "rules_kotlin", version = "2.2.1") -bazel_dep(name = "rules_jvm_external", version = "6.7") +bazel_dep(name = "rules_jvm_external", version = "6.9") maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") maven.install( diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 7632427..2194cae 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -102,7 +102,8 @@ "https://bcr.bazel.build/modules/nlohmann_json/3.12.0.bcr.1/source.json": "93f82a5ae985eb935c539bfee95e04767187818189241ac956f3ccadbdb8fb02", "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", "https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92", - "https://bcr.bazel.build/modules/package_metadata/0.0.2/source.json": "e53a759a72488d2c0576f57491ef2da0cf4aab05ac0997314012495935531b73", + "https://bcr.bazel.build/modules/package_metadata/0.0.3/MODULE.bazel": "77890552ecea9e284b5424c9de827a58099348763a4359e975c359a83d4faa83", + "https://bcr.bazel.build/modules/package_metadata/0.0.3/source.json": "742075a428ad12a3fa18a69014c2f57f01af910c6d9d18646c990200853e641a", "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5", "https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f", "https://bcr.bazel.build/modules/platforms/0.0.11/source.json": "f7e188b79ebedebfe75e9e1d098b8845226c7992b307e28e1496f23112e8fc29", @@ -191,7 +192,8 @@ "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", "https://bcr.bazel.build/modules/rules_jvm_external/6.6/MODULE.bazel": "153042249c7060536dc95b6bb9f9bb8063b8a0b0cb7acdb381bddbc2374aed55", "https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd", - "https://bcr.bazel.build/modules/rules_jvm_external/6.7/source.json": "5426f412d0a7fc6b611643376c7e4a82dec991491b9ce5cb1cfdd25fe2e92be4", + "https://bcr.bazel.build/modules/rules_jvm_external/6.9/MODULE.bazel": "07c5db05527db7744a54fcffd653e1550d40e0540207a7f7e6d0a4de5bef8274", + "https://bcr.bazel.build/modules/rules_jvm_external/6.9/source.json": "b12970214f3cc144b26610caeb101fa622d910f1ab3d98f0bae1058edbd00bd4", "https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59", "https://bcr.bazel.build/modules/rules_kotlin/1.9.5/MODULE.bazel": "043a16a572f610558ec2030db3ff0c9938574e7dd9f58bded1bb07c0192ef025", "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", From c7dde9157ae76755eec2bfd8412218c02e92720b Mon Sep 17 00:00:00 2001 From: Rafael Campos Date: Wed, 25 Feb 2026 08:38:27 -0500 Subject: [PATCH 4/5] tests: increase coverage --- .../com/intuit/playerui/xlr/AllTests.kt | 2 + .../com/intuit/playerui/xlr/TestFixtures.kt | 28 ++ .../playerui/xlr/XlrDeserializerTest.kt | 169 ++++++++---- .../com/intuit/playerui/xlr/XlrGuardsTest.kt | 71 +++-- .../intuit/playerui/xlr/XlrSerializerTest.kt | 151 ++++++++++- .../com/intuit/playerui/xlr/XlrTypesTest.kt | 248 +++++++++++++++--- .../com/intuit/playerui/xlr/XlrUtilityTest.kt | 224 ++++++++++++++++ 7 files changed, 782 insertions(+), 111 deletions(-) create mode 100644 types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrUtilityTest.kt diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/AllTests.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/AllTests.kt index 688b844..a2bad86 100644 --- a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/AllTests.kt +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/AllTests.kt @@ -3,11 +3,13 @@ package com.intuit.playerui.xlr import org.junit.runner.RunWith import org.junit.runners.Suite +// New test classes must be registered here; Bazel uses this suite as the entry point. @RunWith(Suite::class) @Suite.SuiteClasses( XlrDeserializerTest::class, XlrGuardsTest::class, XlrSerializerTest::class, XlrTypesTest::class, + XlrUtilityTest::class, ) class AllTests diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/TestFixtures.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/TestFixtures.kt index 5fbabae..4ada117 100644 --- a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/TestFixtures.kt +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/TestFixtures.kt @@ -7,4 +7,32 @@ object TestFixtures { .bufferedReader() .readText() } + + /** List of all 19 NodeType instances for cross-test reuse. */ + val allNodeTypeInstances: List by lazy { + listOf( + StringType(const = "x"), + NumberType(const = 1.0), + BooleanType(const = true), + NullType(), + AnyType(), + UnknownType(), + UndefinedType(), + VoidType(), + NeverType(), + RefType(ref = "Foo"), + ObjectType(properties = mapOf("a" to ObjectProperty(required = true, node = StringType()))), + ArrayType(elementType = StringType()), + TupleType(elementTypes = listOf(TupleMember(type = StringType())), minItems = 1), + RecordType(keyType = StringType(), valueType = AnyType()), + OrType(orTypes = listOf(StringType(), NumberType())), + AndType(andTypes = listOf(StringType(), NumberType())), + TemplateLiteralType(format = ".*"), + ConditionalType( + check = ConditionalCheck(StringType(), NumberType()), + value = ConditionalValue(BooleanType(), NullType()), + ), + FunctionType(parameters = listOf(FunctionParameter(name = "x", type = StringType()))), + ) + } } diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrDeserializerTest.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrDeserializerTest.kt index 66c8757..43c6be5 100644 --- a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrDeserializerTest.kt +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrDeserializerTest.kt @@ -1,5 +1,9 @@ package com.intuit.playerui.xlr +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -10,30 +14,27 @@ import kotlin.test.assertTrue class XlrDeserializerTest { private val fixtureJson: String get() = TestFixtures.choiceAssetJson + private val doc by lazy { XlrDeserializer.deserialize(fixtureJson) } @Test fun `deserializes document name and source`() { - val doc = XlrDeserializer.deserialize(fixtureJson) assertEquals("ChoiceAsset", doc.name) assertTrue(doc.source.contains("choice/types.ts")) } @Test fun `deserializes document objectType as object`() { - val doc = XlrDeserializer.deserialize(fixtureJson) assertEquals("object", doc.objectType.type) } @Test fun `toObjectType returns equivalent ObjectType`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val objType = doc.toObjectType() assertEquals(doc.objectType, objType) } @Test fun `deserializes properties map with correct keys`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val props = doc.objectType.properties assertTrue(props.containsKey("title")) assertTrue(props.containsKey("note")) @@ -45,7 +46,6 @@ class XlrDeserializerTest { @Test fun `deserializes title property as RefType`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val titleProp = doc.objectType.properties["title"]!! assertEquals(false, titleProp.required) val node = assertIs(titleProp.node) @@ -58,7 +58,6 @@ class XlrDeserializerTest { @Test fun `deserializes binding property as RefType`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val bindingNode = assertIs(doc.objectType.properties["binding"]!!.node) assertEquals("Binding", bindingNode.ref) assertEquals("ChoiceAsset.binding", bindingNode.title) @@ -67,7 +66,6 @@ class XlrDeserializerTest { @Test fun `deserializes items property as ArrayType with nested ObjectType`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val itemsNode = assertIs(doc.objectType.properties["items"]!!.node) val elementType = assertIs(itemsNode.elementType) assertTrue(elementType.properties.containsKey("id")) @@ -77,7 +75,6 @@ class XlrDeserializerTest { @Test fun `deserializes nested ChoiceItem id as required StringType`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val itemsNode = assertIs(doc.objectType.properties["items"]!!.node) val choiceItem = assertIs(itemsNode.elementType) val idProp = choiceItem.properties["id"]!! @@ -87,7 +84,6 @@ class XlrDeserializerTest { @Test fun `deserializes nested ValueType as OrType with 4 members`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val itemsNode = assertIs(doc.objectType.properties["items"]!!.node) val choiceItem = assertIs(itemsNode.elementType) val valueNode = assertIs(choiceItem.properties["value"]!!.node) @@ -100,7 +96,6 @@ class XlrDeserializerTest { @Test fun `deserializes BeaconDataType as OrType with RecordType`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val metaDataNode = assertIs(doc.objectType.properties["metaData"]!!.node) val beaconNode = assertIs(metaDataNode.properties["beacon"]!!.node) assertEquals(2, beaconNode.orTypes.size) @@ -112,7 +107,6 @@ class XlrDeserializerTest { @Test fun `deserializes generic tokens on document`() { - val doc = XlrDeserializer.deserialize(fixtureJson) assertNotNull(doc.genericTokens) assertEquals(1, doc.genericTokens!!.size) val token = doc.genericTokens!!.first() @@ -125,7 +119,6 @@ class XlrDeserializerTest { @Test fun `deserializes generic tokens on nested ChoiceItem`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val itemsNode = assertIs(doc.objectType.properties["items"]!!.node) val choiceItem = assertIs(itemsNode.elementType) assertNotNull(choiceItem.genericTokens) @@ -140,7 +133,6 @@ class XlrDeserializerTest { @Test fun `deserializes source on nested ChoiceItem`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val itemsNode = assertIs(doc.objectType.properties["items"]!!.node) val choiceItem = assertIs(itemsNode.elementType) assertNotNull(choiceItem.source) @@ -149,7 +141,6 @@ class XlrDeserializerTest { @Test fun `deserializes source on nested ValueType`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val itemsNode = assertIs(doc.objectType.properties["items"]!!.node) val choiceItem = assertIs(itemsNode.elementType) val valueNode = assertIs(choiceItem.properties["value"]!!.node) @@ -159,7 +150,6 @@ class XlrDeserializerTest { @Test fun `deserializes source on nested BeaconMetaData`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val metaDataNode = assertIs(doc.objectType.properties["metaData"]!!.node) assertNotNull(metaDataNode.source) assertTrue(metaDataNode.source!!.contains("beacon")) @@ -167,7 +157,6 @@ class XlrDeserializerTest { @Test fun `deserializes source on nested BeaconDataType`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val metaDataNode = assertIs(doc.objectType.properties["metaData"]!!.node) val beaconNode = assertIs(metaDataNode.properties["beacon"]!!.node) assertNotNull(beaconNode.source) @@ -176,7 +165,6 @@ class XlrDeserializerTest { @Test fun `root objectType carries source and genericTokens`() { - val doc = XlrDeserializer.deserialize(fixtureJson) assertNotNull(doc.objectType.source) assertTrue(doc.objectType.source!!.contains("choice/types.ts")) assertNotNull(doc.objectType.genericTokens) @@ -191,7 +179,6 @@ class XlrDeserializerTest { @Test fun `nodes without source or genericTokens default to null`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val bindingNode = assertIs(doc.objectType.properties["binding"]!!.node) assertNull(bindingNode.source) assertNull(bindingNode.genericTokens) @@ -199,7 +186,6 @@ class XlrDeserializerTest { @Test fun `deserializes extends clause with Asset choice`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val ext = doc.objectType.extends assertNotNull(ext) assertEquals("Asset<\"choice\">", ext.ref) @@ -211,26 +197,22 @@ class XlrDeserializerTest { @Test fun `deserializes additionalProperties as None for false`() { - val doc = XlrDeserializer.deserialize(fixtureJson) assertEquals(AdditionalItemsType.None, doc.objectType.additionalProperties) } @Test fun `deserializes annotation title on document`() { - val doc = XlrDeserializer.deserialize(fixtureJson) assertEquals("ChoiceAsset", doc.objectType.title) } @Test fun `deserializes annotation description on document`() { - val doc = XlrDeserializer.deserialize(fixtureJson) assertNotNull(doc.objectType.description) assertTrue(doc.objectType.description!!.startsWith("A choice asset")) } @Test fun `deserializes annotation title on nested nodes`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val titleNode = assertIs(doc.objectType.properties["title"]!!.node) assertEquals("ChoiceAsset.title", titleNode.title) } @@ -238,7 +220,7 @@ class XlrDeserializerTest { @Test fun `parseNode throws for missing type field`() { val json = - kotlinx.serialization.json.Json + Json .parseToJsonElement("""{"ref": "foo"}""") assertFailsWith { XlrDeserializer.parseNode(json) @@ -248,7 +230,7 @@ class XlrDeserializerTest { @Test fun `parseNode throws for unknown type`() { val json = - kotlinx.serialization.json.Json + Json .parseToJsonElement("""{"type": "foobar"}""") assertFailsWith { XlrDeserializer.parseNode(json) @@ -257,14 +239,14 @@ class XlrDeserializerTest { @Test fun `parseNode handles JsonNull as NullType`() { - val node = XlrDeserializer.parseNode(kotlinx.serialization.json.JsonNull) + val node = XlrDeserializer.parseNode(JsonNull) assertIs(node) } @Test fun `parseNode deserializes simple string type`() { val json = - kotlinx.serialization.json.Json + Json .parseToJsonElement("""{"type": "string", "const": "hello"}""") val node = assertIs(XlrDeserializer.parseNode(json)) assertEquals("hello", node.const) @@ -273,7 +255,7 @@ class XlrDeserializerTest { @Test fun `parseNode deserializes string type with enum`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """{"type": "string", "enum": ["a", "b", "c"]}""", ) val node = assertIs(XlrDeserializer.parseNode(json)) @@ -283,7 +265,7 @@ class XlrDeserializerTest { @Test fun `parseNode deserializes number type with enum`() { val json = - kotlinx.serialization.json.Json + Json .parseToJsonElement("""{"type": "number", "enum": [1.0, 2.0, 3.0]}""") val node = assertIs(XlrDeserializer.parseNode(json)) assertEquals(listOf(1.0, 2.0, 3.0), node.enum) @@ -292,7 +274,7 @@ class XlrDeserializerTest { @Test fun `parseNode deserializes boolean type with const`() { val json = - kotlinx.serialization.json.Json + Json .parseToJsonElement("""{"type": "boolean", "const": true}""") val node = assertIs(XlrDeserializer.parseNode(json)) assertEquals(true, node.const) @@ -301,7 +283,7 @@ class XlrDeserializerTest { @Test fun `parseNode deserializes template literal type`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """{"type": "template", "format": "hello_\\d+"}""", ) val node = assertIs(XlrDeserializer.parseNode(json)) @@ -311,7 +293,7 @@ class XlrDeserializerTest { @Test fun `parseNode deserializes conditional type with structured check and value`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """ { "type": "conditional", @@ -330,7 +312,7 @@ class XlrDeserializerTest { @Test fun `parseNode deserializes function type`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """ { "type": "function", @@ -355,7 +337,7 @@ class XlrDeserializerTest { @Test fun `parseNode deserializes function type with parameter default`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """ { "type": "function", @@ -374,7 +356,7 @@ class XlrDeserializerTest { @Test fun `parseNode deserializes ref type with property`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """{"type": "ref", "ref": "Foo", "property": "bar"}""", ) val node = assertIs(XlrDeserializer.parseNode(json)) @@ -385,7 +367,7 @@ class XlrDeserializerTest { @Test fun `parseNode deserializes tuple type`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """ { "type": "tuple", @@ -410,7 +392,7 @@ class XlrDeserializerTest { @Test fun `parseNode deserializes tuple member without name`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """ { "type": "tuple", @@ -428,7 +410,7 @@ class XlrDeserializerTest { @Test fun `parseNode deserializes and type`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """{"type": "and", "and": [{"type": "string"}, {"type": "number"}]}""", ) val node = assertIs(XlrDeserializer.parseNode(json)) @@ -442,7 +424,7 @@ class XlrDeserializerTest { val simpleTypes = listOf("any", "unknown", "undefined", "void", "never", "null") for (typeName in simpleTypes) { val json = - kotlinx.serialization.json.Json + Json .parseToJsonElement("""{"type": "$typeName"}""") val node = XlrDeserializer.parseNode(json) assertEquals(typeName, node.type, "Failed for type: $typeName") @@ -452,7 +434,7 @@ class XlrDeserializerTest { @Test fun `additionalProperties Typed parses NodeType`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """ { "type": "object", @@ -469,7 +451,7 @@ class XlrDeserializerTest { @Test fun `additionalProperties null becomes None`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """{"type": "object", "properties": {}}""", ) val node = assertIs(XlrDeserializer.parseNode(json)) @@ -479,7 +461,7 @@ class XlrDeserializerTest { @Test fun `parseNode preserves source and genericTokens on node`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """ { "type": "object", @@ -503,7 +485,7 @@ class XlrDeserializerTest { @Test fun `throws on RefType missing ref`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """{"type": "ref"}""", ) assertFailsWith { @@ -514,7 +496,7 @@ class XlrDeserializerTest { @Test fun `throws on TemplateLiteralType missing format`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """{"type": "template"}""", ) assertFailsWith { @@ -525,7 +507,7 @@ class XlrDeserializerTest { @Test fun `throws on ArrayType missing elementType`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """{"type": "array"}""", ) assertFailsWith { @@ -536,7 +518,7 @@ class XlrDeserializerTest { @Test fun `throws on RecordType missing keyType`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """{"type": "record", "valueType": {"type": "any"}}""", ) assertFailsWith { @@ -547,7 +529,7 @@ class XlrDeserializerTest { @Test fun `throws on ConditionalType missing check`() { val json = - kotlinx.serialization.json.Json.parseToJsonElement( + Json.parseToJsonElement( """ { "type": "conditional", @@ -559,4 +541,97 @@ class XlrDeserializerTest { XlrDeserializer.parseNode(json) } } + + @Test + fun `parseDocument throws for non-ObjectType root`() { + val json = + Json + .parseToJsonElement( + """{"type": "string", "const": "hello"}""", + ).jsonObject + assertFailsWith { + XlrDeserializer.parseDocument(json) + } + } + + @Test + fun `parseDocument throws when name is missing`() { + val json = + Json + .parseToJsonElement( + """{"type": "object", "properties": {}, "additionalProperties": false, "source": "test.ts"}""", + ).jsonObject + assertFailsWith { + XlrDeserializer.parseDocument(json) + } + } + + @Test + fun `parseDocument throws when source is missing`() { + val json = + Json + .parseToJsonElement( + """{"type": "object", "properties": {}, "additionalProperties": false, "name": "Test"}""", + ).jsonObject + assertFailsWith { + XlrDeserializer.parseDocument(json) + } + } + + @Test + fun `deserialize throws for invalid JSON string`() { + assertFailsWith { + XlrDeserializer.deserialize("not valid json {{{") + } + } + + @Test + fun `parseNode throws for JsonPrimitive`() { + assertFailsWith { + XlrDeserializer.parseNode(JsonPrimitive("hello")) + } + } + + @Test + fun `parseNode deserializes OrType with source and annotations`() { + val json = + Json.parseToJsonElement( + """{"type": "or", "or": [{"type": "string"}], "source": "test.ts", "title": "MyUnion"}""", + ) + val node = assertIs(XlrDeserializer.parseNode(json)) + assertEquals("test.ts", node.source) + assertEquals("MyUnion", node.title) + } + + @Test + fun `parseDocument with genericTokens as explicit JsonNull`() { + val json = + Json + .parseToJsonElement( + """ + { + "type": "object", "properties": {}, "additionalProperties": false, + "name": "Test", "source": "test.ts", "genericTokens": null + } + """.trimIndent(), + ).jsonObject + val doc = XlrDeserializer.parseDocument(json) + assertNull(doc.genericTokens) + } + + @Test + fun `parseDocument with no genericTokens key`() { + val json = + Json + .parseToJsonElement( + """ + { + "type": "object", "properties": {}, "additionalProperties": false, + "name": "Test", "source": "test.ts" + } + """.trimIndent(), + ).jsonObject + val doc = XlrDeserializer.parseDocument(json) + assertNull(doc.genericTokens) + } } diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrGuardsTest.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrGuardsTest.kt index c59680c..8be6746 100644 --- a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrGuardsTest.kt +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrGuardsTest.kt @@ -9,18 +9,7 @@ import kotlin.test.assertTrue class XlrGuardsTest { @Test fun `isPrimitiveType returns true for all primitive types`() { - val primitives = - listOf( - StringType(), - NumberType(), - BooleanType(), - NullType(), - AnyType(), - UnknownType(), - UndefinedType(), - VoidType(), - NeverType(), - ) + val primitives = TestFixtures.allNodeTypeInstances.filter { isPrimitiveType(it) } for (node in primitives) { assertTrue(isPrimitiveType(node), "Expected true for ${node::class.simpleName}") } @@ -28,22 +17,7 @@ class XlrGuardsTest { @Test fun `isPrimitiveType returns false for non-primitive types`() { - val nonPrimitives: List = - listOf( - ObjectType(properties = emptyMap()), - ArrayType(elementType = StringType()), - RefType(ref = "Foo"), - OrType(orTypes = listOf(StringType())), - AndType(andTypes = listOf(StringType())), - RecordType(keyType = StringType(), valueType = AnyType()), - TupleType(elementTypes = emptyList(), minItems = 0), - TemplateLiteralType(format = ".*"), - ConditionalType( - check = ConditionalCheck(StringType(), NumberType()), - value = ConditionalValue(BooleanType(), NullType()), - ), - FunctionType(parameters = emptyList()), - ) + val nonPrimitives = TestFixtures.allNodeTypeInstances.filter { !isPrimitiveType(it) } for (node in nonPrimitives) { assertFalse(isPrimitiveType(node), "Expected false for ${node::class.simpleName}") } @@ -220,4 +194,45 @@ class XlrGuardsTest { ) assertEquals(listOf("a", 3.0), getLiteralValues(union)) } + + @Test + fun `isAssetRef returns false for similar but non-matching ref`() { + assertFalse(isAssetRef(RefType(ref = "Assets"))) + } + + @Test + fun `isBindingRef returns false for prefix-only match`() { + assertFalse(isBindingRef(RefType(ref = "BindingFoo"))) + } + + @Test + fun `isExpressionRef returns false for prefix-only match`() { + assertFalse(isExpressionRef(RefType(ref = "ExpressionFoo"))) + } + + @Test + fun `isLiteralUnion returns true for empty OrType`() { + assertTrue(isLiteralUnion(OrType(orTypes = emptyList()))) + } + + @Test + fun `getLiteralValues returns empty list for empty OrType`() { + assertEquals(emptyList(), getLiteralValues(OrType(orTypes = emptyList()))) + } + + @Test + fun `extractAssetTypeConstant returns null when genericArguments is null`() { + val ref = RefType(ref = "Asset<\"choice\">") + assertNull(extractAssetTypeConstant(ref)) + } + + @Test + fun `extractAssetTypeConstant returns null for StringType without const`() { + val ref = + RefType( + ref = "Asset<\"choice\">", + genericArguments = listOf(StringType()), + ) + assertNull(extractAssetTypeConstant(ref)) + } } diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrSerializerTest.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrSerializerTest.kt index f73b503..97de135 100644 --- a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrSerializerTest.kt +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrSerializerTest.kt @@ -1,5 +1,7 @@ package com.intuit.playerui.xlr +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject @@ -13,18 +15,17 @@ import kotlin.test.assertTrue class XlrSerializerTest { private val fixtureJson: String get() = TestFixtures.choiceAssetJson + private val doc by lazy { XlrDeserializer.deserialize(fixtureJson) } @Test fun `round-trip deserialize-serialize-deserialize produces equal documents`() { - val doc1 = XlrDeserializer.deserialize(fixtureJson) - val serialized = XlrSerializer.serialize(doc1) + val serialized = XlrSerializer.serialize(doc) val doc2 = XlrDeserializer.deserialize(serialized) - assertEquals(doc1, doc2) + assertEquals(doc, doc2) } @Test fun `serializes document name and source`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val jsonObj = XlrSerializer.serializeDocument(doc) assertEquals("ChoiceAsset", jsonObj["name"]?.jsonPrimitive?.content) assertTrue( @@ -37,7 +38,6 @@ class XlrSerializerTest { @Test fun `serializes document genericTokens`() { - val doc = XlrDeserializer.deserialize(fixtureJson) val jsonObj = XlrSerializer.serializeDocument(doc) val tokens = jsonObj["genericTokens"] assertNotNull(tokens) @@ -470,4 +470,145 @@ class XlrSerializerTest { val json = XlrSerializer.serializeNode(tuple) assertEquals(JsonPrimitive(false), json["additionalItems"]) } + + @Test + fun `serialize returns parseable JSON string`() { + val doc = XlrDocument(name = "Test", source = "test.ts", objectType = ObjectType()) + val jsonString = XlrSerializer.serialize(doc) + val parsed = + Json.parseToJsonElement(jsonString) + assertIs(parsed) + } + + @Test + fun `serialize round-trip via string form`() { + val doc = + XlrDocument( + name = "Test", + source = "test.ts", + objectType = + ObjectType( + properties = mapOf("x" to ObjectProperty(required = true, node = StringType())), + ), + ) + val jsonString = XlrSerializer.serialize(doc) + val doc2 = XlrDeserializer.deserialize(jsonString) + assertEquals(doc.name, doc2.name) + assertEquals(doc.source, doc2.source) + assertEquals(doc.objectType.properties, doc2.objectType.properties) + } + + @Test + fun `serializeDocument omits genericTokens when null`() { + val doc = XlrDocument(name = "Test", source = "test.ts", objectType = ObjectType()) + val jsonObj = XlrSerializer.serializeDocument(doc) + assertFalse(jsonObj.containsKey("genericTokens")) + } + + @Test + fun `serializeDocument includes genericTokens when present`() { + val doc = + XlrDocument( + name = "Test", + source = "test.ts", + objectType = ObjectType(), + genericTokens = listOf(ParamTypeNode(symbol = "T")), + ) + val jsonObj = XlrSerializer.serializeDocument(doc) + assertTrue(jsonObj.containsKey("genericTokens")) + } + + @Test + fun `serializeNode preserves JsonElement annotation fields`() { + val examples = + Json.parseToJsonElement("""["a", "b"]""") + val defaultVal = + Json.parseToJsonElement(""""hello"""") + val see = + Json.parseToJsonElement("""{"ref": "OtherType"}""") + val node = StringType(examples = examples, default = defaultVal, see = see) + val jsonObj = XlrSerializer.serializeNode(node) + assertEquals(examples, jsonObj["examples"]) + assertEquals(defaultVal, jsonObj["default"]) + assertEquals(see, jsonObj["see"]) + } + + @Test + fun `round-trip all 19 node types`() { + for (original in TestFixtures.allNodeTypeInstances) { + val json = XlrSerializer.serializeNode(original) + val deserialized = XlrDeserializer.parseNode(json) + assertEquals(original, deserialized, "Round-trip failed for ${original::class.simpleName}") + } + } + + @Test + fun `serializeDocument flattens ObjectType at root`() { + val doc = + XlrDocument( + name = "Test", + source = "test.ts", + objectType = + ObjectType( + properties = mapOf("x" to ObjectProperty(required = true, node = StringType())), + ), + ) + val jsonObj = XlrSerializer.serializeDocument(doc) + assertEquals("object", jsonObj["type"]?.jsonPrimitive?.content) + assertFalse(jsonObj.containsKey("objectType")) + assertTrue(jsonObj.containsKey("properties")) + } + + @Test + fun `serializeDocument name and source override ObjectType values`() { + val obj = ObjectType(name = "InnerName", source = "inner.ts") + val doc = XlrDocument(name = "OuterName", source = "outer.ts", objectType = obj) + val jsonObj = XlrSerializer.serializeDocument(doc) + assertEquals("OuterName", jsonObj["name"]?.jsonPrimitive?.content) + assertEquals("outer.ts", jsonObj["source"]?.jsonPrimitive?.content) + } + + @Test + fun `serializeDocument preserves annotations on ObjectType root`() { + val doc = + XlrDocument( + name = "Test", + source = "test.ts", + objectType = + ObjectType( + title = "TestTitle", + description = "A test object", + comment = "Some comment", + ), + ) + val jsonObj = XlrSerializer.serializeDocument(doc) + assertEquals("TestTitle", jsonObj["title"]?.jsonPrimitive?.content) + assertEquals("A test object", jsonObj["description"]?.jsonPrimitive?.content) + assertEquals("Some comment", jsonObj["comment"]?.jsonPrimitive?.content) + } + + @Test + fun `serialize round-trip with genericTokens`() { + val doc = + XlrDocument( + name = "Test", + source = "test.ts", + objectType = ObjectType(), + genericTokens = + listOf( + ParamTypeNode( + symbol = "T", + constraints = RefType(ref = "Asset"), + default = RefType(ref = "Asset"), + ), + ), + ) + val jsonString = XlrSerializer.serialize(doc) + val doc2 = XlrDeserializer.deserialize(jsonString) + assertEquals(doc.name, doc2.name) + assertEquals(doc.source, doc2.source) + assertNotNull(doc2.genericTokens) + assertEquals(1, doc2.genericTokens!!.size) + assertEquals("T", doc2.genericTokens!!.first().symbol) + } } diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrTypesTest.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrTypesTest.kt index 7ef4bfe..3372bb5 100644 --- a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrTypesTest.kt +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrTypesTest.kt @@ -1,13 +1,22 @@ package com.intuit.playerui.xlr import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull class XlrTypesTest { + private val json = Json { ignoreUnknownKeys = true } + @Test fun `StringType defaults`() { val s = StringType() @@ -192,43 +201,15 @@ class XlrTypesTest { @Test fun `polymorphic serialization round-trip via NodeType serializer`() { - val jsonInstance = - Json { - ignoreUnknownKeys = true - } val original: NodeType = StringType(const = "test", title = "MyTitle") - val serialized = jsonInstance.encodeToString(NodeType.serializer(), original) - val deserialized = jsonInstance.decodeFromString(NodeType.serializer(), serialized) + val serialized = json.encodeToString(NodeType.serializer(), original) + val deserialized = json.decodeFromString(NodeType.serializer(), serialized) assertEquals(original, deserialized) } @Test fun `NodeType sealed hierarchy includes all 19 types`() { - val instances: List = - listOf( - StringType(), - NumberType(), - BooleanType(), - NullType(), - AnyType(), - UnknownType(), - UndefinedType(), - VoidType(), - NeverType(), - RefType(ref = "Foo"), - ObjectType(), - ArrayType(elementType = StringType()), - TupleType(elementTypes = emptyList(), minItems = 0), - RecordType(keyType = StringType(), valueType = AnyType()), - OrType(orTypes = emptyList()), - AndType(andTypes = emptyList()), - TemplateLiteralType(format = ".*"), - ConditionalType( - check = ConditionalCheck(StringType(), NumberType()), - value = ConditionalValue(BooleanType(), NullType()), - ), - FunctionType(parameters = emptyList()), - ) + val instances = TestFixtures.allNodeTypeInstances assertEquals(19, instances.size) for (instance in instances) { assertNotNull(instance.type) @@ -257,4 +238,209 @@ class XlrTypesTest { assertEquals(true, prop.required) assertIs(prop.node) } + + @Test + fun `RecordType defaults`() { + val r = RecordType(keyType = StringType(), valueType = AnyType()) + assertEquals("record", r.type) + assertNull(r.name) + assertNull(r.title) + assertNull(r.description) + assertNull(r.source) + assertNull(r.genericTokens) + } + + @Test + fun `ArrayType defaults`() { + val a = ArrayType(elementType = StringType()) + assertEquals("array", a.type) + assertNull(a.name) + assertNull(a.source) + assertNull(a.genericTokens) + } + + @Test + fun `TemplateLiteralType defaults`() { + val t = TemplateLiteralType(format = ".*") + assertEquals("template", t.type) + assertNull(t.name) + assertNull(t.source) + assertNull(t.genericTokens) + } + + @Test + fun `OrType defaults`() { + val o = OrType(orTypes = listOf(StringType())) + assertEquals("or", o.type) + assertNull(o.name) + assertNull(o.source) + assertNull(o.genericTokens) + } + + @Test + fun `AndType defaults`() { + val a = AndType(andTypes = listOf(StringType())) + assertEquals("and", a.type) + assertNull(a.name) + assertNull(a.source) + assertNull(a.genericTokens) + } + + @Test + fun `RefType defaults`() { + val r = RefType(ref = "Foo") + assertEquals("ref", r.type) + assertNull(r.genericArguments) + assertNull(r.property) + assertNull(r.name) + assertNull(r.source) + assertNull(r.genericTokens) + } + + @Test + fun `FunctionType defaults`() { + val f = FunctionType(parameters = emptyList()) + assertEquals("function", f.type) + assertNull(f.returnType) + assertNull(f.name) + assertNull(f.source) + assertNull(f.genericTokens) + } + + @Test + fun `FunctionParameter with all fields`() { + val p = + FunctionParameter( + name = "x", + type = StringType(), + optional = true, + default = StringType(const = "hi"), + ) + assertEquals("x", p.name) + assertIs(p.type) + assertEquals(true, p.optional) + val defaultNode = assertIs(p.default) + assertEquals("hi", defaultNode.const) + } + + @Test + fun `TupleMember defaults`() { + val m = TupleMember(type = StringType()) + assertNull(m.name) + assertNull(m.optional) + assertIs(m.type) + } + + @Test + fun `XlrDocument defaults`() { + val doc = XlrDocument(name = "Test", source = "test.ts", objectType = ObjectType()) + assertEquals("Test", doc.name) + assertEquals("test.ts", doc.source) + assertNull(doc.genericTokens) + } + + @Test + fun `data class equality for identical instances`() { + val a = StringType(const = "hello", title = "Title") + val b = StringType(const = "hello", title = "Title") + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun `data class inequality for different field values`() { + val a = StringType(const = "hello") + val b = StringType(const = "world") + assertNotEquals(a, b) + } + + @Test + fun `data class copy with modified fields`() { + val original = StringType(const = "hello", title = "T") + val copied = original.copy(const = "world") + assertEquals("world", copied.const) + assertEquals("T", copied.title) + } + + @Test + fun `StringType with JsonElement annotations`() { + val examples = Json.parseToJsonElement("""["a", "b"]""") + val defaultVal = Json.parseToJsonElement(""""hello"""") + val see = Json.parseToJsonElement("""{"ref": "OtherType"}""") + val s = StringType(examples = examples, default = defaultVal, see = see) + assertIs(s.examples) + assertIs(s.default) + assertIs(s.see) + } + + @Test + fun `WholeNumberDoubleSerializer serializes negative whole number as integer`() { + val element = json.encodeToJsonElement(NodeType.serializer(), NumberType(const = -42.0)) + assertEquals(JsonPrimitive(-42), element.jsonObject["const"]) + } + + @Test + fun `WholeNumberDoubleSerializer serializes zero as integer`() { + val element = json.encodeToJsonElement(NodeType.serializer(), NumberType(const = 0.0)) + assertEquals(JsonPrimitive(0), element.jsonObject["const"]) + } + + @Test + fun `WholeNumberDoubleSerializer serializes large whole number as integer`() { + val element = json.encodeToJsonElement(NodeType.serializer(), NumberType(const = 1000000.0)) + assertEquals(JsonPrimitive(1000000), element.jsonObject["const"]) + } + + @Test + fun `AdditionalItemsTypeSerializer encodes None to false`() { + val encoded = json.encodeToJsonElement(AdditionalItemsTypeSerializer, AdditionalItemsType.None) + assertEquals(JsonPrimitive(false), encoded) + } + + @Test + fun `AdditionalItemsTypeSerializer encodes Typed to node object`() { + val encoded = + json.encodeToJsonElement( + AdditionalItemsTypeSerializer, + AdditionalItemsType.Typed(StringType()), + ) + assertIs(encoded) + assertEquals("string", encoded.jsonObject["type"]?.jsonPrimitive?.content) + } + + @Test + fun `AdditionalItemsTypeSerializer decodes false to None`() { + val decoded = json.decodeFromJsonElement(AdditionalItemsTypeSerializer, JsonPrimitive(false)) + assertIs(decoded) + } + + @Test + fun `AdditionalItemsTypeSerializer decodes JsonNull to None`() { + val decoded = json.decodeFromJsonElement(AdditionalItemsTypeSerializer, JsonNull) + assertIs(decoded) + } + + @Test + fun `AdditionalItemsTypeSerializer decodes node object to Typed`() { + val nodeJson = json.parseToJsonElement("""{"type": "string"}""") + val decoded = json.decodeFromJsonElement(AdditionalItemsTypeSerializer, nodeJson) + val typed = assertIs(decoded) + assertIs(typed.node) + } + + @Test + fun `AdditionalItemsTypeSerializer round-trips None`() { + val original = AdditionalItemsType.None + val encoded = json.encodeToJsonElement(AdditionalItemsTypeSerializer, original) + val decoded = json.decodeFromJsonElement(AdditionalItemsTypeSerializer, encoded) + assertEquals(original, decoded) + } + + @Test + fun `AdditionalItemsTypeSerializer round-trips Typed`() { + val original = AdditionalItemsType.Typed(StringType(const = "test")) + val encoded = json.encodeToJsonElement(AdditionalItemsTypeSerializer, original) + val decoded = json.decodeFromJsonElement(AdditionalItemsTypeSerializer, encoded) + assertEquals(original, decoded) + } } diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrUtilityTest.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrUtilityTest.kt new file mode 100644 index 0000000..c596e37 --- /dev/null +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrUtilityTest.kt @@ -0,0 +1,224 @@ +package com.intuit.playerui.xlr + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class XlrUtilityTest { + // Intentionally omits isLenient=true (present in production xlrJson) to test with stricter parsing. + private val json = + Json { + encodeDefaults = false + ignoreUnknownKeys = true + } + + @Test + fun `Capability construction`() { + val cap = Capability(name = "MyCapability", provides = listOf("TypeA", "TypeB")) + assertEquals("MyCapability", cap.name) + assertEquals(listOf("TypeA", "TypeB"), cap.provides) + } + + @Test + fun `Capability serialization round-trip`() { + val original = Capability(name = "MyCap", provides = listOf("A", "B")) + val encoded = json.encodeToString(Capability.serializer(), original) + val decoded = json.decodeFromString(Capability.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun `Capability with empty provides list`() { + val cap = Capability(name = "EmptyCap", provides = emptyList()) + assertEquals(emptyList(), cap.provides) + } + + @Test + fun `Manifest construction with all fields`() { + val manifest = + Manifest( + pluginName = "TestPlugin", + capabilities = mapOf("cap1" to listOf("TypeA")), + customPrimitives = listOf("CustomString"), + ) + assertEquals("TestPlugin", manifest.pluginName) + assertEquals(mapOf("cap1" to listOf("TypeA")), manifest.capabilities) + assertEquals(listOf("CustomString"), manifest.customPrimitives) + } + + @Test + fun `Manifest with defaults`() { + val manifest = Manifest(pluginName = "TestPlugin") + assertEquals("TestPlugin", manifest.pluginName) + assertNull(manifest.capabilities) + assertNull(manifest.customPrimitives) + } + + @Test + fun `Manifest serialization round-trip with all fields`() { + val original = + Manifest( + pluginName = "TestPlugin", + capabilities = mapOf("cap1" to listOf("TypeA", "TypeB")), + customPrimitives = listOf("CustomString"), + ) + val encoded = json.encodeToString(Manifest.serializer(), original) + val decoded = json.decodeFromString(Manifest.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun `Manifest serialization omits null optionals`() { + val manifest = Manifest(pluginName = "TestPlugin") + val encoded = json.encodeToJsonElement(Manifest.serializer(), manifest) + val obj = encoded.jsonObject + assertEquals(setOf("pluginName"), obj.keys) + } + + @Test + fun `TSManifest construction`() { + val namedType: NamedType = + NamedType( + typeName = "MyType", + source = "test.ts", + node = StringType(const = "value"), + ) + val manifest = + TSManifest( + pluginName = "TestPlugin", + capabilities = mapOf("cap1" to listOf(namedType)), + ) + assertEquals("TestPlugin", manifest.pluginName) + assertEquals(1, manifest.capabilities.size) + assertEquals(1, manifest.capabilities["cap1"]?.size) + } + + @Test + fun `TSManifest serialization round-trip`() { + val namedType: NamedType = + NamedType( + typeName = "MyType", + source = "test.ts", + node = ObjectType(), + ) + val original = + TSManifest( + pluginName = "TestPlugin", + capabilities = mapOf("cap1" to listOf(namedType)), + ) + val encoded = json.encodeToString(TSManifest.serializer(), original) + val decoded = json.decodeFromString(TSManifest.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun `TransformInput Named construction`() { + val namedType: NamedType = NamedType(typeName = "Foo", source = "foo.ts", node = StringType()) + val input = TransformInput.Named(namedType) + assertEquals(namedType, input.namedType) + } + + @Test + fun `TransformInput Anonymous construction`() { + val nodeType: NodeType = StringType(const = "hello") + val input = TransformInput.Anonymous(nodeType) + assertEquals(nodeType, input.nodeType) + } + + @Test + fun `TransformInput sealed variants distinguished via is checks`() { + val named: TransformInput = + TransformInput.Named( + NamedType(typeName = "Foo", source = "foo.ts", node = StringType()), + ) + val anonymous: TransformInput = TransformInput.Anonymous(StringType()) + assertIs(named) + assertIs(anonymous) + } + + @Test + fun `TransformInput Named data class equality`() { + val nt: NamedType = NamedType(typeName = "Foo", source = "foo.ts", node = StringType()) + val a = TransformInput.Named(nt) + val b = TransformInput.Named(nt) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun `TransformInput Anonymous data class equality`() { + val a = TransformInput.Anonymous(StringType(const = "x")) + val b = TransformInput.Anonymous(StringType(const = "x")) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun `TransformOutput Named construction`() { + val namedType: NamedType = NamedType(typeName = "Bar", source = "bar.ts", node = NumberType()) + val output = TransformOutput.Named(namedType) + assertEquals(namedType, output.namedType) + } + + @Test + fun `TransformOutput Anonymous construction`() { + val nodeType: NodeType = NumberType(const = 42.0) + val output = TransformOutput.Anonymous(nodeType) + assertEquals(nodeType, output.nodeType) + } + + @Test + fun `TransformOutput sealed variants distinguished via is checks`() { + val named: TransformOutput = + TransformOutput.Named( + NamedType(typeName = "Bar", source = "bar.ts", node = NumberType()), + ) + val anonymous: TransformOutput = TransformOutput.Anonymous(NumberType()) + assertIs(named) + assertIs(anonymous) + } + + @Test + fun `TransformOutput data class equality`() { + val nt: NamedType = NamedType(typeName = "Bar", source = "bar.ts", node = NumberType()) + val a = TransformOutput.Named(nt) + val b = TransformOutput.Named(nt) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun `TransformFunction invocation with Named input`() { + val transform = + TransformFunction { input, capabilityType -> + val named = input as TransformInput.Named + TransformOutput.Anonymous( + StringType(const = "${named.namedType.typeName}_$capabilityType"), + ) + } + val input = + TransformInput.Named( + NamedType(typeName = "Foo", source = "foo.ts", node = ObjectType()), + ) + val output = transform.transform(input, "asset") + val anonymous = assertIs(output) + val strType = assertIs(anonymous.nodeType) + assertEquals("Foo_asset", strType.const) + } + + @Test + fun `TransformFunction invocation with Anonymous input`() { + val transform = + TransformFunction { input, _ -> + val anon = input as TransformInput.Anonymous + TransformOutput.Anonymous(anon.nodeType) + } + val input = TransformInput.Anonymous(NumberType(const = 42.0)) + val output = transform.transform(input, "binding") + val anonymous = assertIs(output) + assertIs(anonymous.nodeType) + } +} From 89ecc69d1f7f75e9165e3057ffd656e5f41bc948 Mon Sep 17 00:00:00 2001 From: Rafael Campos Date: Wed, 25 Feb 2026 09:57:37 -0500 Subject: [PATCH 5/5] chore: kotlin releases --- .circleci/config.yml | 1 + MODULE.bazel | 16 ++++- scripts/release.sh | 14 +++- types/kotlin/BUILD.bazel | 67 ++++++++----------- types/kotlin/pom.tpl | 44 ++++++++++++ .../com/intuit/playerui/xlr/AllTests.kt | 15 ----- 6 files changed, 98 insertions(+), 59 deletions(-) create mode 100644 types/kotlin/pom.tpl delete mode 100644 types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/AllTests.kt diff --git a/.circleci/config.yml b/.circleci/config.yml index 9bc3401..88b4655 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -37,6 +37,7 @@ commands: - setup_auth - run: echo "//registry.npmjs.com/:_authToken=$NPM_TOKEN" >> ~/.npmrc - run: echo -e $GPG_KEY | gpg --import --batch + - run: echo -e "pinentry-mode loopback\npassphrase $DEPLOY_MAVEN_GPG_PASSPHRASE" > ~/.gnupg/gpg.conf - run: | source ~/.bashrc npx auto shipit --only-graduate-with-release-label -vv diff --git a/MODULE.bazel b/MODULE.bazel index 1b63f79..73752e1 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -76,8 +76,17 @@ maven.install( "org.jetbrains.kotlin:kotlin-stdlib:2.3.0", "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0", "org.jetbrains.kotlin:kotlin-test:2.3.0", - "org.jetbrains.kotlin:kotlin-test-junit:2.3.0", - "junit:junit:4.13.2", + "org.jetbrains.kotlin:kotlin-test-junit5:2.3.0", + "org.junit.jupiter:junit-jupiter-api:5.7.2", + "org.junit.jupiter:junit-jupiter-engine:5.7.2", + "org.junit.jupiter:junit-jupiter-params:5.7.2", + "org.junit.platform:junit-platform-commons:1.7.2", + "org.junit.platform:junit-platform-console:1.7.2", + "org.junit.platform:junit-platform-engine:1.7.2", + "org.junit.platform:junit-platform-launcher:1.7.2", + "org.junit.platform:junit-platform-suite-api:1.7.2", + "org.apiguardian:apiguardian-api:1.0.0", + "org.opentest4j:opentest4j:1.1.1", ], repositories = [ "https://repo1.maven.org/maven2", @@ -89,5 +98,8 @@ use_repo(maven, "maven") build_constants = use_repo_rule("@rules_player//distribution:defs.bzl", "build_constants") build_constants( name = "build_constants", + constants = { + "GROUP": "com.intuit.playerui.xlr", + }, version_file = "//:VERSION", ) diff --git a/scripts/release.sh b/scripts/release.sh index 1d24acd..9a4233a 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # See https://github.com/bazelbuild/rules_nodejs/blob/stable/scripts/publish_release.sh -set -u -o pipefail +set -eu -o pipefail readonly PKG_NPM_LABELS=`bazel query --output=label 'kind("npm_package rule", //...) - attr("tags", "\[.*do-not-publish.*\]", //...)'` NPM_TAG=canary @@ -29,4 +29,14 @@ readonly PKG_PYPI_LABELS=`bazel query --output=label 'kind("py_wheel rule", //.. for pkg in $PKG_PYPI_LABELS ; do TWINE_USERNAME=$PYPI_USER TWINE_PASSWORD=$PYPI_TOKEN bazel run --config=release ${pkg}.publish -- -done \ No newline at end of file +done + +# Maven Central Publishing +MVN_RELEASE_TYPE=snapshot +if [ "$RELEASE_TYPE" == "release" ] && [ "$CURRENT_BRANCH" == "main" ]; then + MVN_RELEASE_TYPE=release +fi + +echo "Publishing Maven Packages with release type: ${MVN_RELEASE_TYPE} on branch: ${CURRENT_BRANCH}" +bazel build --config=release @rules_player//distribution:staged-maven-deploy +bazel run --config=release @rules_player//distribution:staged-maven-deploy -- "$MVN_RELEASE_TYPE" --package-group=com.intuit.playerui.xlr --client-timeout=600 --connect-timeout=600 diff --git a/types/kotlin/BUILD.bazel b/types/kotlin/BUILD.bazel index d113269..d3c071b 100644 --- a/types/kotlin/BUILD.bazel +++ b/types/kotlin/BUILD.bazel @@ -1,16 +1,18 @@ +load("@rules_player//kotlin:defs.bzl", "kt_jvm") load("@rules_kotlin//kotlin:core.bzl", "kt_compiler_plugin") -load("@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library", "kt_jvm_test") -load("@rules_kotlin//kotlin:lint.bzl", "ktlint_config", "ktlint_fix", "ktlint_test") +load("@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library") +load("@rules_kotlin//kotlin:lint.bzl", "ktlint_config") +load("@build_constants//:constants.bzl", "GROUP", "VERSION") package(default_visibility = ["//visibility:public"]) ktlint_config( - name = "ktlint_config", + name = "lint_config", editorconfig = ".editorconfig", ) kt_compiler_plugin( - name = "kotlinx_serialization_plugin", + name = "serialization_plugin", compile_phase = True, id = "org.jetbrains.kotlin.serialization", stubs_phase = True, @@ -20,47 +22,32 @@ kt_compiler_plugin( ) kt_jvm_library( - name = "xlr-types", - srcs = glob(["src/main/kotlin/**/*.kt"]), - kotlinc_opts = "//:kt_opts", - plugins = [":kotlinx_serialization_plugin"], - deps = [ - "@maven//:org_jetbrains_kotlin_kotlin_stdlib", + name = "kotlin_serialization", + srcs = [], + exported_compiler_plugins = [":serialization_plugin"], + exports = [ "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", ], ) -kt_jvm_test( - name = "xlr-types-test", - srcs = glob(["src/test/kotlin/**/*.kt"]), - kotlinc_opts = "//:kt_opts", - plugins = [":kotlinx_serialization_plugin"], - resource_strip_prefix = "types/kotlin/src/test/resources", - resources = glob(["src/test/resources/**"]), - test_class = "com.intuit.playerui.xlr.AllTests", - deps = [ - ":xlr-types", - "@maven//:junit_junit", +kt_jvm( + name = "xlr-types", + group = GROUP, + version = VERSION, + lint_config = ":lint_config", + main_opts = "//:kt_opts", + main_deps = [ + ":kotlin_serialization", + "@maven//:org_jetbrains_kotlin_kotlin_stdlib", + ], + test_package = "com.intuit.playerui.xlr", + test_opts = "//:kt_opts", + test_deps = [ "@maven//:org_jetbrains_kotlin_kotlin_test", - "@maven//:org_jetbrains_kotlin_kotlin_test_junit", + "@maven//:org_jetbrains_kotlin_kotlin_test_junit5", "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", ], -) - -ktlint_test( - name = "ktlint", - srcs = glob([ - "src/main/kotlin/**/*.kt", - "src/test/kotlin/**/*.kt", - ]), - config = ":ktlint_config", -) - -ktlint_fix( - name = "ktlint_fix", - srcs = glob([ - "src/main/kotlin/**/*.kt", - "src/test/kotlin/**/*.kt", - ]), - config = ":ktlint_config", + test_resources = glob(["src/test/resources/**/*"]), + test_resource_strip_prefix = "types/kotlin/src/test/resources", + pom_template = ":pom.tpl", ) diff --git a/types/kotlin/pom.tpl b/types/kotlin/pom.tpl new file mode 100644 index 0000000..f070425 --- /dev/null +++ b/types/kotlin/pom.tpl @@ -0,0 +1,44 @@ + + + 4.0.0 + + XLR - {artifactId} + Kotlin XLR type definitions, serializer and deserializer + https://github.com/player-ui/xlr + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + rafbcampos + Rafael Campos + + + sugarmanz + Jeremiah Zucker + + + brocollie08 + Tony Lin + + + + https://github.com/player-ui/xlr.git + https://github.com/player-ui/xlr.git + v{version} + https://github.com/player-ui/xlr.git + + + {groupId} + {artifactId} + {version} + {type} + + +{dependencies} + + diff --git a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/AllTests.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/AllTests.kt deleted file mode 100644 index a2bad86..0000000 --- a/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/AllTests.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.intuit.playerui.xlr - -import org.junit.runner.RunWith -import org.junit.runners.Suite - -// New test classes must be registered here; Bazel uses this suite as the entry point. -@RunWith(Suite::class) -@Suite.SuiteClasses( - XlrDeserializerTest::class, - XlrGuardsTest::class, - XlrSerializerTest::class, - XlrTypesTest::class, - XlrUtilityTest::class, -) -class AllTests