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/.circleci/config.yml b/.circleci/config.yml index d7436be..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 @@ -101,7 +102,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..73752e1 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -66,8 +66,40 @@ pip.parse( use_repo(pip, "pypi") +####### Kotlin ######### +bazel_dep(name = "rules_kotlin", version = "2.2.1") +bazel_dep(name = "rules_jvm_external", version = "6.9") + +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-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", + ], +) +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/MODULE.bazel.lock b/MODULE.bazel.lock index 77c5f34..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,12 +192,14 @@ "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", "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 +436,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/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/.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..d3c071b --- /dev/null +++ b/types/kotlin/BUILD.bazel @@ -0,0 +1,53 @@ +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") +load("@rules_kotlin//kotlin:lint.bzl", "ktlint_config") +load("@build_constants//:constants.bzl", "GROUP", "VERSION") + +package(default_visibility = ["//visibility:public"]) + +ktlint_config( + name = "lint_config", + editorconfig = ".editorconfig", +) + +kt_compiler_plugin( + name = "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 = "kotlin_serialization", + srcs = [], + exported_compiler_plugins = [":serialization_plugin"], + exports = [ + "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", + ], +) + +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_junit5", + "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", + ], + 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/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..9b2565f --- /dev/null +++ b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrDeserializer.kt @@ -0,0 +1,42 @@ +package com.intuit.playerui.xlr + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object XlrDeserializer { + fun deserialize(jsonString: String): XlrDocument { + val element = xlrJson.parseToJsonElement(jsonString).jsonObject + return parseDocument(element) + } + + fun parseDocument(obj: JsonObject): XlrDocument { + val node = xlrJson.decodeFromJsonElement(NodeType.serializer(), obj) + val objectType = + node as? ObjectType + ?: throw IllegalArgumentException( + "Document root must be an object type, got: ${obj["type"]}", + ) + 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 }?.let { + xlrJson.decodeFromJsonElement(it) + }, + ) + } + + fun parseNode(element: JsonElement): NodeType { + if (element is JsonNull) return NullType() + return xlrJson.decodeFromJsonElement(NodeType.serializer(), element) + } +} 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/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 new file mode 100644 index 0000000..d3420fc --- /dev/null +++ b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrSerializer.kt @@ -0,0 +1,27 @@ +package com.intuit.playerui.xlr + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +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 { + 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", xlrJson.encodeToJsonElement(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 new file mode 100644 index 0000000..5c0799f --- /dev/null +++ b/types/kotlin/src/main/kotlin/com/intuit/playerui/xlr/XlrTypes.kt @@ -0,0 +1,540 @@ +@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.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. + * 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(with = AdditionalItemsTypeSerializer::class) +sealed interface AdditionalItemsType { + data object None : AdditionalItemsType + + 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 }`. + */ +@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. + */ +@Serializable +@JsonClassDiscriminator("type") +sealed interface NodeType : Annotated { + val type: String + val source: String? + val genericTokens: List? +} + +@Serializable +@SerialName("string") +data class StringType( + @Transient 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( + @Transient 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( + @Transient 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( + @Transient 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( + @Transient 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( + @Transient 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( + @Transient 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( + @Transient 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( + @Transient 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( + @Transient 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( + @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, + 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( + @Transient 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( + @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, + 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( + @Transient 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 + +// 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( + @Transient 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 + +// 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( + @Transient 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( + @Transient 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( + @Transient 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( + @Transient 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 +} + +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/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/TestFixtures.kt b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/TestFixtures.kt new file mode 100644 index 0000000..4ada117 --- /dev/null +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/TestFixtures.kt @@ -0,0 +1,38 @@ +package com.intuit.playerui.xlr + +object TestFixtures { + val choiceAssetJson: String by lazy { + TestFixtures::class.java + .getResourceAsStream("/test.json")!! + .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 new file mode 100644 index 0000000..43c6be5 --- /dev/null +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrDeserializerTest.kt @@ -0,0 +1,637 @@ +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 +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +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`() { + assertEquals("ChoiceAsset", doc.name) + assertTrue(doc.source.contains("choice/types.ts")) + } + + @Test + fun `deserializes document objectType as object`() { + assertEquals("object", doc.objectType.type) + } + + @Test + fun `toObjectType returns equivalent ObjectType`() { + val objType = doc.toObjectType() + assertEquals(doc.objectType, objType) + } + + @Test + fun `deserializes properties map with correct keys`() { + 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 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 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 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 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 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 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`() { + 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 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 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 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 metaDataNode = assertIs(doc.objectType.properties["metaData"]!!.node) + assertNotNull(metaDataNode.source) + assertTrue(metaDataNode.source!!.contains("beacon")) + } + + @Test + fun `deserializes source on nested BeaconDataType`() { + 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`() { + 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 bindingNode = assertIs(doc.objectType.properties["binding"]!!.node) + assertNull(bindingNode.source) + assertNull(bindingNode.genericTokens) + } + + @Test + fun `deserializes extends clause with Asset choice`() { + 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`() { + assertEquals(AdditionalItemsType.None, doc.objectType.additionalProperties) + } + + @Test + fun `deserializes annotation title on document`() { + assertEquals("ChoiceAsset", doc.objectType.title) + } + + @Test + fun `deserializes annotation description on document`() { + assertNotNull(doc.objectType.description) + assertTrue(doc.objectType.description!!.startsWith("A choice asset")) + } + + @Test + fun `deserializes annotation title on nested nodes`() { + val titleNode = assertIs(doc.objectType.properties["title"]!!.node) + assertEquals("ChoiceAsset.title", titleNode.title) + } + + @Test + fun `parseNode throws for missing type field`() { + val json = + Json + .parseToJsonElement("""{"ref": "foo"}""") + assertFailsWith { + XlrDeserializer.parseNode(json) + } + } + + @Test + fun `parseNode throws for unknown type`() { + val json = + Json + .parseToJsonElement("""{"type": "foobar"}""") + assertFailsWith { + XlrDeserializer.parseNode(json) + } + } + + @Test + fun `parseNode handles JsonNull as NullType`() { + val node = XlrDeserializer.parseNode(JsonNull) + assertIs(node) + } + + @Test + fun `parseNode deserializes simple string type`() { + val 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + Json.parseToJsonElement( + """{"type": "ref"}""", + ) + assertFailsWith { + XlrDeserializer.parseNode(json) + } + } + + @Test + fun `throws on TemplateLiteralType missing format`() { + val json = + Json.parseToJsonElement( + """{"type": "template"}""", + ) + assertFailsWith { + XlrDeserializer.parseNode(json) + } + } + + @Test + fun `throws on ArrayType missing elementType`() { + val json = + Json.parseToJsonElement( + """{"type": "array"}""", + ) + assertFailsWith { + XlrDeserializer.parseNode(json) + } + } + + @Test + fun `throws on RecordType missing keyType`() { + val json = + Json.parseToJsonElement( + """{"type": "record", "valueType": {"type": "any"}}""", + ) + assertFailsWith { + XlrDeserializer.parseNode(json) + } + } + + @Test + fun `throws on ConditionalType missing check`() { + val json = + Json.parseToJsonElement( + """ + { + "type": "conditional", + "value": {"true": {"type": "string"}, "false": {"type": "null"}} + } + """.trimIndent(), + ) + assertFailsWith { + 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 new file mode 100644 index 0000000..8be6746 --- /dev/null +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrGuardsTest.kt @@ -0,0 +1,238 @@ +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 = TestFixtures.allNodeTypeInstances.filter { isPrimitiveType(it) } + for (node in primitives) { + assertTrue(isPrimitiveType(node), "Expected true for ${node::class.simpleName}") + } + } + + @Test + fun `isPrimitiveType returns false for non-primitive types`() { + val nonPrimitives = TestFixtures.allNodeTypeInstances.filter { !isPrimitiveType(it) } + 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)) + } + + @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 new file mode 100644 index 0000000..97de135 --- /dev/null +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrSerializerTest.kt @@ -0,0 +1,614 @@ +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 +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 get() = TestFixtures.choiceAssetJson + private val doc by lazy { XlrDeserializer.deserialize(fixtureJson) } + + @Test + fun `round-trip deserialize-serialize-deserialize produces equal documents`() { + val serialized = XlrSerializer.serialize(doc) + val doc2 = XlrDeserializer.deserialize(serialized) + assertEquals(doc, doc2) + } + + @Test + fun `serializes document name and source`() { + 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 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) + } + + @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"]) + } + + @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 new file mode 100644 index 0000000..3372bb5 --- /dev/null +++ b/types/kotlin/src/test/kotlin/com/intuit/playerui/xlr/XlrTypesTest.kt @@ -0,0 +1,446 @@ +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() + 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 `polymorphic serialization round-trip via NodeType serializer`() { + val original: NodeType = StringType(const = "test", title = "MyTitle") + 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 = TestFixtures.allNodeTypeInstances + 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) + } + + @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) + } +} 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