diff --git a/gradle.properties b/gradle.properties index 2a946654..d1b50c97 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ kotlin.code.style=official kotlin.js.compiler=ir -org.gradle.jvmargs=-Xmx1G +org.gradle.jvmargs=-Xmx1536M org.gradle.java.installations.auto-download=false org.gradle.daemon=false diff --git a/json-schema-validator-objects/api/json-schema-validator-objects.api b/json-schema-validator-objects/api/json-schema-validator-objects.api new file mode 100644 index 00000000..71d1134c --- /dev/null +++ b/json-schema-validator-objects/api/json-schema-validator-objects.api @@ -0,0 +1,16 @@ +public final class io/github/optimumcode/json/schema/wrappers/objects/ObjectWrappers { + public static final fun wrapAsElement (Ljava/lang/Object;)Lio/github/optimumcode/json/schema/model/AbstractElement; + public static final fun wrapAsElement (Ljava/lang/Object;Lio/github/optimumcode/json/schema/wrappers/objects/WrappingConfiguration;)Lio/github/optimumcode/json/schema/model/AbstractElement; + public static synthetic fun wrapAsElement$default (Ljava/lang/Object;Lio/github/optimumcode/json/schema/wrappers/objects/WrappingConfiguration;ILjava/lang/Object;)Lio/github/optimumcode/json/schema/model/AbstractElement; + public static final fun wrappingConfiguration ()Lio/github/optimumcode/json/schema/wrappers/objects/WrappingConfiguration; + public static final fun wrappingConfiguration (Z)Lio/github/optimumcode/json/schema/wrappers/objects/WrappingConfiguration; + public static final fun wrappingConfiguration (ZZ)Lio/github/optimumcode/json/schema/wrappers/objects/WrappingConfiguration; + public static synthetic fun wrappingConfiguration$default (ZZILjava/lang/Object;)Lio/github/optimumcode/json/schema/wrappers/objects/WrappingConfiguration; +} + +public final class io/github/optimumcode/json/schema/wrappers/objects/WrappingConfiguration { + public fun ()V + public final fun getAllowSets ()Z + public final fun getCharAsCodepoint ()Z +} + diff --git a/json-schema-validator-objects/build.gradle.kts b/json-schema-validator-objects/build.gradle.kts new file mode 100644 index 00000000..d88ab19c --- /dev/null +++ b/json-schema-validator-objects/build.gradle.kts @@ -0,0 +1,150 @@ +@file:OptIn(ExperimentalWasmDsl::class) + +import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.plugin.KotlinTarget +import org.jetbrains.kotlin.gradle.plugin.KotlinTargetWithTests +import org.jlleitschuh.gradle.ktlint.reporter.ReporterType + +plugins { + alias(libs.plugins.kotlin.mutliplatform) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotest.multiplatform) + alias(libs.plugins.kover) + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) + alias(libs.plugins.kotlin.dokka) + convention.publication +} + +kotlin { + explicitApi() + + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + freeCompilerArgs.add("-opt-in=io.github.optimumcode.json.schema.ExperimentalApi") + } + jvmToolchain(11) + jvm { + testRuns["test"].executionTask.configure { + useJUnitPlatform() + } + } + js(IR) { + browser() + generateTypeScriptDefinitions() + nodejs() + } + wasmJs { + // The wasmJsBrowserTest prints all executed tests as one unformatted string + // Have not found a way to suppress printing all this into console + browser() + nodejs() + } + + applyDefaultHierarchyTemplate() + + val macOsTargets = + listOf( + macosX64(), + macosArm64(), + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ) + + val linuxTargets = + listOf( + linuxX64(), + linuxArm64(), + ) + + val windowsTargets = + listOf( + mingwX64(), + ) + + sourceSets { + commonMain { + dependencies { + api(projects.jsonSchemaValidator) + } + } + + val noJsMain by creating { + dependsOn(commonMain.get()) + } + + jvmMain { + dependsOn(noJsMain) + } + + wasmJsMain { + dependsOn(noJsMain) + } + + nativeMain { + dependsOn(noJsMain) + } + + commonTest { + dependencies { + implementation(libs.kotest.assertions.core) + implementation(libs.kotest.framework.engine) + implementation(kotlin("test-common")) + implementation(kotlin("test-annotations-common")) + } + } + jvmTest { + dependencies { + implementation(libs.kotest.runner.junit5) + } + } + } + + afterEvaluate { + fun Task.dependsOnTargetTests(targets: List) { + targets.forEach { + if (it is KotlinTargetWithTests<*, *>) { + dependsOn(tasks.getByName("${it.name}Test")) + } + } + } + tasks.register("macOsAllTest") { + group = "verification" + description = "runs all tests for MacOS and IOS targets" + dependsOnTargetTests(macOsTargets) + } + tasks.register("windowsAllTest") { + group = "verification" + description = "runs all tests for Windows targets" + dependsOnTargetTests(windowsTargets) + } + tasks.register("linuxAllTest") { + group = "verification" + description = "runs all tests for Linux targets" + dependsOnTargetTests(linuxTargets) + dependsOn(tasks.getByName("jvmTest")) + dependsOn(tasks.getByName("jsTest")) + dependsOn(tasks.getByName("wasmJsTest")) + } + } +} + +ktlint { + version.set(libs.versions.ktlint) + reporters { + reporter(ReporterType.HTML) + } +} + +afterEvaluate { + val detektAllTask by tasks.register("detektAll") { + dependsOn(tasks.withType()) + } + + tasks.named("check").configure { + dependsOn(detektAllTask) + } +} \ No newline at end of file diff --git a/json-schema-validator-objects/src/commonMain/kotlin/io/github/optimumcode/json/schema/wrappers/objects/Wrappers.kt b/json-schema-validator-objects/src/commonMain/kotlin/io/github/optimumcode/json/schema/wrappers/objects/Wrappers.kt new file mode 100644 index 00000000..4e310228 --- /dev/null +++ b/json-schema-validator-objects/src/commonMain/kotlin/io/github/optimumcode/json/schema/wrappers/objects/Wrappers.kt @@ -0,0 +1,228 @@ +@file:JvmName("ObjectWrappers") +@file:Suppress("detekt:MatchingDeclarationName") + +package io.github.optimumcode.json.schema.wrappers.objects + +import io.github.optimumcode.json.schema.ExperimentalApi +import io.github.optimumcode.json.schema.model.AbstractElement +import io.github.optimumcode.json.schema.model.ArrayElement +import io.github.optimumcode.json.schema.model.ObjectElement +import io.github.optimumcode.json.schema.model.PrimitiveElement +import kotlin.jvm.JvmInline +import kotlin.jvm.JvmName +import kotlin.jvm.JvmOverloads + +@ExperimentalApi +public class WrappingConfiguration internal constructor( + /** + * If set to `false` an exception is thrown when wrapping a [Set]. + * If set to `true`, [Set] is wrapped the same way as [List] + */ + public val allowSets: Boolean = false, + /** + * If set to `false` the [Char] is converted to [String]. + * If set to `true` the [Char] is converted to a codepoint (and then to [Long]) + */ + public val charAsCodepoint: Boolean = false, +) + +@ExperimentalApi +@JvmOverloads +public fun wrappingConfiguration( + allowSets: Boolean = false, + charAsCodepoint: Boolean = false, +): WrappingConfiguration = + WrappingConfiguration( + allowSets = allowSets, + charAsCodepoint = charAsCodepoint, + ) + +/** + * Returns an [AbstractElement] produced by converting the [obj] value. + * The [configuration] allows conversion customization. + * + * # The supported types + * + * ## Simple values: + * * [String] + * * [Byte] + * * [Short] + * * [Int] + * * [Long] + * * [Float] + * * [Double] + * * [Boolean] + * * [Char] + * * `null` + * + * ## Structures: + * * [Map] -> keys MUST have a [String] type, values MUST be one of the supported types + * * [List] -> elements MUST be one of the supported types + * * [Array] -> elements MUST be one of the supported types + * + * If [WrappingConfiguration.allowSets] is enabled [Set] is also converted to [ArrayElement]. + * Please be aware that in order to have consistent verification results + * the [Set] must be one of the ORDERED types, e.g. [LinkedHashSet]. + */ +@JvmOverloads +@ExperimentalApi +public fun wrapAsElement( + obj: Any?, + configuration: WrappingConfiguration = WrappingConfiguration(), +): AbstractElement { + if (obj == null) { + return NullWrapper + } + return when { + obj is Map<*, *> -> checkKeysAndWrap(obj, configuration) + obj is List<*> -> ListWrapper(obj.map { wrapAsElement(it, configuration) }) + obj is Array<*> -> ListWrapper(obj.map { wrapAsElement(it, configuration) }) + obj is Set<*> && configuration.allowSets -> + ListWrapper(obj.map { wrapAsElement(it, configuration) }) + + isPrimitive(obj) -> PrimitiveWrapper(convertToSupportedType(obj, configuration)) + else -> error("unsupported type to wrap: ${obj::class}") + } +} + +private fun isPrimitive(obj: Any): Boolean = obj is String || obj is Number || obj is Boolean || obj is Char + +/** + * Returns `true` if the [value] is an integer ([Byte], [Short], [Int], [Long]). + * Otherwise, returns `false`. + * + * Required because JS platform matches all types except Long with `number` type. + * Refer to the [KT-18177](https://youtrack.jetbrains.com/issue/KT-18177/) for additional details + */ +internal expect fun isInteger(value: Number): Boolean + +private fun convertToSupportedType( + obj: Any, + configuration: WrappingConfiguration, +): Any = + when { + obj !is Number -> + if (obj is Char) { + if (configuration.charAsCodepoint) obj.code.toLong() else obj.toString() + } else { + obj + } + obj is Long -> obj + isInteger(obj) -> obj.toLong() + obj is Double -> obj + // due to KT-18177 this won't be invoked for Float on JS platform + obj is Float -> obj.toDoubleSafe() + else -> error("unsupported number type: ${obj::class}") + } + +private fun Float.toDoubleSafe(): Double { + val double = toDouble() + // in some cases the conversion from float to double + // can introduce a difference between numbers. (e.g. 42.2f -> 42.2) + // In this case, the only way (at the moment) is to try parsing + // the double from float converted to string + val floatAsString = toString() + if (double.toString() == floatAsString) { + return double + } + return floatAsString.toDouble() +} + +private fun checkKeysAndWrap( + map: Map<*, *>, + configuration: WrappingConfiguration, +): ObjectWrapper { + if (map.isEmpty()) { + return ObjectWrapper(emptyMap()) + } + + require(map.keys.all { it is String }) { + val notStrings = + map.keys.asSequence().filterNot { it is String }.mapTo(hashSetOf()) { key -> + key?.let { it::class.simpleName } ?: "null" + }.joinToString() + "map keys must be strings, found: $notStrings" + } + + @Suppress("UNCHECKED_CAST") + val elementsMap = + map.mapValues { (_, value) -> + wrapAsElement(value, configuration) + } as Map + return ObjectWrapper(elementsMap) +} + +@JvmInline +private value class ObjectWrapper( + private val map: Map, +) : ObjectElement { + override val keys: Set + get() = map.keys + + override fun get(key: String): AbstractElement? = map[key] + + override fun contains(key: String): Boolean = map.containsKey(key) + + override val size: Int + get() = map.size + + override fun iterator(): Iterator> = + map.asSequence().map { (key, value) -> key to value }.iterator() + + override fun toString(): String = map.toString() +} + +@JvmInline +private value class ListWrapper( + private val list: List, +) : ArrayElement { + override fun iterator(): Iterator = list.iterator() + + override fun get(index: Int): AbstractElement = list[index] + + override val size: Int + get() = list.size + + override fun toString(): String = list.toString() +} + +@JvmInline +private value class PrimitiveWrapper( + private val value: Any, +) : PrimitiveElement { + override val isNull: Boolean + get() = false + override val isString: Boolean + get() = value is String + override val isBoolean: Boolean + get() = value is Boolean + override val isNumber: Boolean + get() = value is Number + override val longOrNull: Long? + get() = value as? Long + override val doubleOrNull: Double? + get() = value as? Double + override val content: String + get() = value.toString() + + override fun toString(): String = value.toString() +} + +private data object NullWrapper : PrimitiveElement { + override val isNull: Boolean + get() = true + override val isString: Boolean + get() = false + override val isBoolean: Boolean + get() = false + override val isNumber: Boolean + get() = false + override val longOrNull: Long? + get() = null + override val doubleOrNull: Double? + get() = null + override val content: String + get() = "null" + + override fun toString(): String = "null" +} \ No newline at end of file diff --git a/json-schema-validator-objects/src/commonTest/kotlin/io/github/optimumcode/json/schema/wrappers/objects/ValidationTest.kt b/json-schema-validator-objects/src/commonTest/kotlin/io/github/optimumcode/json/schema/wrappers/objects/ValidationTest.kt new file mode 100644 index 00000000..efc680b0 --- /dev/null +++ b/json-schema-validator-objects/src/commonTest/kotlin/io/github/optimumcode/json/schema/wrappers/objects/ValidationTest.kt @@ -0,0 +1,116 @@ +package io.github.optimumcode.json.schema.wrappers.objects + +import io.github.optimumcode.json.pointer.JsonPointer +import io.github.optimumcode.json.schema.JsonSchema +import io.github.optimumcode.json.schema.OutputCollector +import io.github.optimumcode.json.schema.SchemaType +import io.github.optimumcode.json.schema.ValidationOutput +import io.kotest.assertions.asClue +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe + +class ValidationTest : FunSpec() { + init { + val schema = + JsonSchema.fromDefinition( + """ + { + "properties": { + "simple": { + "type": "integer" + }, + "collection": { + "type": "array", + "items": { + "type": "string" + } + }, + "sub-map": { + "properties": { + "inner": { + "type": "string" + } + } + } + }, + "additionalProperties": false, + "required": ["simple"] + } + """.trimIndent(), + defaultType = SchemaType.DRAFT_2020_12, + ) + + test("valid object") { + val result = + schema.validate( + wrapAsElement( + mapOf( + "simple" to 1, + "collection" to + listOf( + "test1", + "test2", + ), + "sub-map" to + mapOf( + "inner" to "inner1", + ), + ), + ), + OutputCollector.basic(), + ) + + result.asClue { + it.valid shouldBe true + } + } + + test("invalid object") { + val result = + schema.validate( + wrapAsElement( + mapOf( + "simple" to 1.5, + "collection" to + listOf( + "test1", + 1, + ), + "sub-map" to + mapOf( + "inner" to 42, + ), + ), + ), + OutputCollector.basic(), + ) + + result.asClue { + it.valid shouldBe false + it.errors shouldHaveSize 3 + it.errors.shouldContainExactlyInAnyOrder( + ValidationOutput.OutputUnit( + valid = false, + keywordLocation = JsonPointer("/properties/simple/type"), + instanceLocation = JsonPointer("/simple"), + error = "element is not a integer", + ), + ValidationOutput.OutputUnit( + valid = false, + keywordLocation = JsonPointer("/properties/collection/items/type"), + instanceLocation = JsonPointer("/collection/1"), + error = "element is not a string", + ), + ValidationOutput.OutputUnit( + valid = false, + keywordLocation = JsonPointer("/properties/sub-map/properties/inner/type"), + instanceLocation = JsonPointer("/sub-map/inner"), + error = "element is not a string", + ), + ) + } + } + } +} \ No newline at end of file diff --git a/json-schema-validator-objects/src/commonTest/kotlin/io/github/optimumcode/json/schema/wrappers/objects/WrappersTest.kt b/json-schema-validator-objects/src/commonTest/kotlin/io/github/optimumcode/json/schema/wrappers/objects/WrappersTest.kt new file mode 100644 index 00000000..b525b219 --- /dev/null +++ b/json-schema-validator-objects/src/commonTest/kotlin/io/github/optimumcode/json/schema/wrappers/objects/WrappersTest.kt @@ -0,0 +1,308 @@ +package io.github.optimumcode.json.schema.wrappers.objects + +import io.github.optimumcode.json.schema.model.ArrayElement +import io.github.optimumcode.json.schema.model.ObjectElement +import io.github.optimumcode.json.schema.model.PrimitiveElement +import io.kotest.assertions.asClue +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.Platform +import io.kotest.core.platform +import io.kotest.core.spec.style.FunSpec +import io.kotest.core.test.Enabled +import io.kotest.core.test.EnabledOrReasonIf +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldStartWith +import io.kotest.matchers.types.shouldBeInstanceOf + +class WrappersTest : FunSpec() { + init { + fun Any?.str(): String = + when (this) { + is Array<*> -> this.contentToString() + else -> toString() + } + + fun Any?.type(): String = this?.let { "(${it::class.simpleName}) " } ?: "" + + mapOf( + emptyMap() to ObjectElement::class, + listOf() to ArrayElement::class, + emptyArray() to ArrayElement::class, + "test" to PrimitiveElement::class, + 't' to PrimitiveElement::class, + 42 to PrimitiveElement::class, + 42L to PrimitiveElement::class, + 42.2 to PrimitiveElement::class, + 42.2f to PrimitiveElement::class, + true to PrimitiveElement::class, + null to PrimitiveElement::class, + ).forEach { (obj, wrapperClass) -> + test("element ${obj.str()} ${obj.type()}is wrapped into ${wrapperClass.simpleName}") { + wrapperClass.isInstance(wrapAsElement(obj)).shouldBeTrue() + } + } + + test("primitive wrapper for null") { + wrapAsElement(null).shouldBeInstanceOf { el -> + assertSoftly { + "isString".asClue { el.isString.shouldBeFalse() } + "isNumber".asClue { el.isNumber.shouldBeFalse() } + "isBoolean".asClue { el.isBoolean.shouldBeFalse() } + "isNull".asClue { el.isNull.shouldBeTrue() } + "content".asClue { el.content shouldBe "null" } + "longOrNull".asClue { el.longOrNull.shouldBeNull() } + "doubleOrNull".asClue { el.doubleOrNull.shouldBeNull() } + } + } + } + + test("primitive wrapper for boolean") { + wrapAsElement(true).shouldBeInstanceOf { el -> + assertSoftly { + "isString".asClue { el.isString.shouldBeFalse() } + "isNumber".asClue { el.isNumber.shouldBeFalse() } + "isBoolean".asClue { el.isBoolean.shouldBeTrue() } + "isNull".asClue { el.isNull.shouldBeFalse() } + "content".asClue { el.content shouldBe "true" } + "longOrNull".asClue { el.longOrNull.shouldBeNull() } + "doubleOrNull".asClue { el.doubleOrNull.shouldBeNull() } + } + } + } + + test("primitive wrapper for integer number") { + wrapAsElement(42).shouldBeInstanceOf { el -> + assertSoftly { + "isString".asClue { el.isString.shouldBeFalse() } + "isNumber".asClue { el.isNumber.shouldBeTrue() } + "isBoolean".asClue { el.isBoolean.shouldBeFalse() } + "isNull".asClue { el.isNull.shouldBeFalse() } + "content".asClue { el.content shouldBe "42" } + "longOrNull".asClue { el.longOrNull shouldBe 42L } + "doubleOrNull".asClue { el.doubleOrNull.shouldBeNull() } + } + } + } + + test("primitive wrapper for floating number") { + wrapAsElement(42.5).shouldBeInstanceOf { el -> + assertSoftly { + "isString".asClue { el.isString.shouldBeFalse() } + "isNumber".asClue { el.isNumber.shouldBeTrue() } + "isBoolean".asClue { el.isBoolean.shouldBeFalse() } + "isNull".asClue { el.isNull.shouldBeFalse() } + "content".asClue { el.content shouldBe "42.5" } + "longOrNull".asClue { el.longOrNull.shouldBeNull() } + "doubleOrNull".asClue { el.doubleOrNull shouldBe 42.5 } + } + } + } + + test("primitive wrapper for string") { + wrapAsElement("42").shouldBeInstanceOf { el -> + assertSoftly { + "isString".asClue { el.isString.shouldBeTrue() } + "isNumber".asClue { el.isNumber.shouldBeFalse() } + "isBoolean".asClue { el.isBoolean.shouldBeFalse() } + "isNull".asClue { el.isNull.shouldBeFalse() } + "content".asClue { el.content shouldBe "42" } + "longOrNull".asClue { el.longOrNull.shouldBeNull() } + "doubleOrNull".asClue { el.doubleOrNull.shouldBeNull() } + } + } + } + + test("primitive wrapper for char") { + wrapAsElement('4').shouldBeInstanceOf { el -> + assertSoftly { + "isString".asClue { el.isString.shouldBeTrue() } + "isNumber".asClue { el.isNumber.shouldBeFalse() } + "isBoolean".asClue { el.isBoolean.shouldBeFalse() } + "isNull".asClue { el.isNull.shouldBeFalse() } + "content".asClue { el.content shouldBe "4" } + "longOrNull".asClue { el.longOrNull.shouldBeNull() } + "doubleOrNull".asClue { el.doubleOrNull.shouldBeNull() } + } + } + } + + test("primitive wrapper for char as codepoint") { + wrapAsElement('4', wrappingConfiguration(charAsCodepoint = true)) + .shouldBeInstanceOf { el -> + assertSoftly { + "isString".asClue { el.isString.shouldBeFalse() } + "isNumber".asClue { el.isNumber.shouldBeTrue() } + "isBoolean".asClue { el.isBoolean.shouldBeFalse() } + "isNull".asClue { el.isNull.shouldBeFalse() } + "content".asClue { el.content shouldBe "52" } + "longOrNull".asClue { el.longOrNull shouldBe 52L } + "doubleOrNull".asClue { el.doubleOrNull.shouldBeNull() } + } + } + } + + test("object wrapper") { + wrapAsElement( + buildMap { + put("a", "hello") + put("b", listOf()) + put("c", mapOf()) + put("d", null) + }, + ).shouldBeInstanceOf { + assertSoftly { + it.size shouldBe 4 + it.keys shouldContainExactly setOf("a", "b", "c", "d") + it["a"].shouldBeInstanceOf() + it["b"].shouldBeInstanceOf() + it["c"].shouldBeInstanceOf() + it["d"].shouldBeInstanceOf { + it.isNull.shouldBeTrue() + } + it["e"].shouldBeNull() + ("a" in it).shouldBeTrue() + ("e" in it).shouldBeFalse() + } + } + } + + test("array wrapper") { + wrapAsElement( + buildList { + add("hello") + add(mapOf()) + add(listOf()) + add(null) + }, + ).shouldBeInstanceOf { + assertSoftly { + it.size shouldBe 4 + it[0].shouldBeInstanceOf() + it[1].shouldBeInstanceOf() + it[2].shouldBeInstanceOf() + it[3].shouldBeInstanceOf { + it.isNull.shouldBeTrue() + } + } + } + } + + test("set is not allowed by default") { + shouldThrow { + wrapAsElement(setOf("a")) + }.message.shouldStartWith("unsupported type to wrap:") + } + + test("set is allowed if configuration is provided") { + val element = + shouldNotThrowAny { + wrapAsElement( + setOf("a"), + wrappingConfiguration( + allowSets = true, + ), + ) + } + element.shouldBeInstanceOf { + it.size shouldBe 1 + it[0].shouldBeInstanceOf() + } + } + + mapOf(42 to "Int", null to "null").forEach { (key, type) -> + test("map with key '${key.str()}' ${key.type()} is not allowed") { + shouldThrow { + wrapAsElement( + mapOf(key to "test"), + ) + }.message.shouldBe("map keys must be strings, found: $type") + } + } + + mapOf( + 42.toByte() to 42L, + 42.toShort() to 42L, + 42 to 42L, + 42L to 42L, + ).forEach { (originalNumber, convertedNumber) -> + val name = + "integer number $originalNumber ${originalNumber.type()}" + + "converted to $convertedNumber ${convertedNumber.type()}" + test(name) { + wrapAsElement(originalNumber).shouldBeInstanceOf { + it.longOrNull.shouldNotBeNull() + .shouldBe(convertedNumber) + it.doubleOrNull.shouldBeNull() + } + } + } + + class DoubleConversionTestCase( + val initial: Any, + val expected: Double, + ) + + mapOf( + DoubleConversionTestCase( + 42.2f, + 42.2, + ) to { + if (platform == Platform.WasmJs) { + Enabled.disabled("problems with precision on wasm platform") + } else { + Enabled.enabled + } + }, + DoubleConversionTestCase(42.5f, 42.5) to { Enabled.enabled }, + DoubleConversionTestCase(42.5, 42.5) to { Enabled.enabled }, + ).forEach { (tc, condition) -> + val originalNumber = tc.initial + val convertedNumber = tc.expected + val name = + "floating number $originalNumber ${originalNumber.type()}" + + "converted to $convertedNumber ${convertedNumber.type()}" + test(name).config(enabledOrReasonIf = condition) { + wrapAsElement(originalNumber).shouldBeInstanceOf { + it.doubleOrNull.shouldNotBeNull() + .shouldBe(convertedNumber) + it.longOrNull.shouldBeNull() + } + } + } + + test("other number implementations are not allowed") + .config( + enabledOrReasonIf = { + when (platform) { + Platform.JS -> Enabled.disabled("you cannot create a class that is a Number on JS") + else -> Enabled.enabled + } + }, + ) { + shouldThrow { + wrapAsElement(MyNumber()) + }.message.shouldStartWith("unsupported number type:") + } + } +} + +private class MyNumber : Number() { + override fun toByte(): Byte = TODO("Not yet implemented") + + override fun toDouble(): Double = TODO("Not yet implemented") + + override fun toFloat(): Float = TODO("Not yet implemented") + + override fun toInt(): Int = TODO("Not yet implemented") + + override fun toLong(): Long = TODO("Not yet implemented") + + override fun toShort(): Short = TODO("Not yet implemented") +} \ No newline at end of file diff --git a/json-schema-validator-objects/src/jsMain/kotlin/io/github/optimumcode/json/schema/wrappers/objects/Wrappers.js.kt b/json-schema-validator-objects/src/jsMain/kotlin/io/github/optimumcode/json/schema/wrappers/objects/Wrappers.js.kt new file mode 100644 index 00000000..badaf6ad --- /dev/null +++ b/json-schema-validator-objects/src/jsMain/kotlin/io/github/optimumcode/json/schema/wrappers/objects/Wrappers.js.kt @@ -0,0 +1,3 @@ +package io.github.optimumcode.json.schema.wrappers.objects + +internal actual fun isInteger(value: Number): Boolean = js("return Number.isInteger(value)") \ No newline at end of file diff --git a/json-schema-validator-objects/src/noJsMain/kotlin/io/github/optimumcode/json/schema/wrappers/objects/Wrappers.noJs.kt b/json-schema-validator-objects/src/noJsMain/kotlin/io/github/optimumcode/json/schema/wrappers/objects/Wrappers.noJs.kt new file mode 100644 index 00000000..f3704d17 --- /dev/null +++ b/json-schema-validator-objects/src/noJsMain/kotlin/io/github/optimumcode/json/schema/wrappers/objects/Wrappers.noJs.kt @@ -0,0 +1,3 @@ +package io.github.optimumcode.json.schema.wrappers.objects + +internal actual fun isInteger(value: Number): Boolean = value is Byte || value is Short || value is Int || value is Long \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 6925b470..65290fcb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,4 +4,5 @@ rootProject.name = "json-schema-validator-root" include(":test-suites") include(":benchmark") -include(":json-schema-validator") \ No newline at end of file +include(":json-schema-validator") +include(":json-schema-validator-objects") \ No newline at end of file