diff --git a/core/build.gradle.kts b/core/build.gradle.kts index db130e4..dbe5c6b 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -12,6 +12,7 @@ plugins { dependencies { implementation(libs.kotlin) + implementation(libs.kotlinReflect) testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.spek) diff --git a/core/src/main/kotlin/fr/xgouchet/elmyr/Forge.kt b/core/src/main/kotlin/fr/xgouchet/elmyr/Forge.kt index a991579..e4a6a68 100644 --- a/core/src/main/kotlin/fr/xgouchet/elmyr/Forge.kt +++ b/core/src/main/kotlin/fr/xgouchet/elmyr/Forge.kt @@ -8,6 +8,7 @@ import kotlin.math.round import kotlin.math.roundToInt import kotlin.math.roundToLong import kotlin.math.sqrt +import kotlin.reflect.KClass /** * The base class to generate forgeries. @@ -18,6 +19,7 @@ open class Forge { private val rng = Random() private val factories: MutableMap, ForgeryFactory<*>> = mutableMapOf() + private val reflexiveFactory by lazy { ReflexiveFactory(this) } // region Reproducibility @@ -61,39 +63,48 @@ open class Forge { * @throws [IllegalArgumentException] if no compatible factory exists */ inline fun getForgery(): T { - return getForgery(T::class.java) + return getForgery(T::class) } /** * @param T the type of the instance to be forged - * @param clazz the class of type T + * @param clazz the [Class] of type T * @return a new instance of type T, randomly forged with available factories * @throws [IllegalArgumentException] if no compatible factory exists */ + @Suppress("UNCHECKED_CAST") fun getForgery(clazz: Class): T { - - @Suppress("UNCHECKED_CAST") val strictMatch = factories[clazz] as? ForgeryFactory - + val fuzzyMatch = factories.filterKeys { + clazz.isAssignableFrom(it) + }.values return when { clazz.isEnum -> anElementFrom(*clazz.enumConstants) - strictMatch == null -> getSubclassForgery(clazz) - else -> strictMatch.getForgery(this) + strictMatch != null -> strictMatch.getForgery(this) + fuzzyMatch.isNotEmpty() -> (anElementFrom(fuzzyMatch.toList()) as ForgeryFactory).getForgery(this) + else -> throw ForgeryFactoryMissingException(clazz = clazz) } } - private fun getSubclassForgery(clazz: Class): T { - - val matches = factories.filterKeys { - clazz.isAssignableFrom(it) + /** + * @param T the type of the instance to be forged + * @param kClass the [KClass] of type T + * @return a new instance of type T, randomly forged with available factories + * @throws [IllegalArgumentException] if no compatible factory exists + */ + @Suppress("UNCHECKED_CAST") + fun getForgery(kClass: KClass): T { + val jClass = kClass.java + val strictMatch = factories[jClass] as? ForgeryFactory + val fuzzyMatch = factories.filterKeys { + jClass.isAssignableFrom(it) }.values - - if (matches.isEmpty()) { - throw ForgeryFactoryMissingException(clazz = clazz) + return when { + jClass.isEnum -> anElementFrom(*jClass.enumConstants) + strictMatch != null -> strictMatch.getForgery(this) + fuzzyMatch.isNotEmpty() -> (anElementFrom(fuzzyMatch.toList()) as ForgeryFactory).getForgery(this) + else -> reflexiveFactory.getForgery(kClass) } - val factory = anElementFrom(matches.toList()) - @Suppress("UNCHECKED_CAST") - return (factory as ForgeryFactory).getForgery(this) } // endregion diff --git a/core/src/main/kotlin/fr/xgouchet/elmyr/ForgeryException.kt b/core/src/main/kotlin/fr/xgouchet/elmyr/ForgeryException.kt index 9ce1f96..7ebbfe1 100644 --- a/core/src/main/kotlin/fr/xgouchet/elmyr/ForgeryException.kt +++ b/core/src/main/kotlin/fr/xgouchet/elmyr/ForgeryException.kt @@ -10,3 +10,29 @@ open class ForgeryException( message: String? = null, cause: Throwable? = null ) : RuntimeException(message, cause) + +/** + * Raised by a [Forge] when asked to forge an instance with no compatible [ForgeryFactory]. + * + * @param clazz the class that was being forged + */ +class ForgeryFactoryMissingException( + clazz: Class<*> +) : ForgeryException( + "Cannot create forgery for type ${clazz.canonicalName}.\n" + + "Make sure you provide a factory for this type." +) + +/** + * Raised by a [Forge] when asked to forge an instance with no compatible [ForgeryFactory]. + * + * @param clazz the class that was being forged + * @param message the message about the reflexive issue preventing the forgery + */ +class ReflexiveForgeryFactoryException( + clazz: Class<*>, + message: String? +) : ForgeryException( + "Cannot create an automatic forgery for type ${clazz.canonicalName}: $message.\n" + + "You can try providing a factory for this type." +) diff --git a/core/src/main/kotlin/fr/xgouchet/elmyr/ForgeryFactoryMissingException.kt b/core/src/main/kotlin/fr/xgouchet/elmyr/ForgeryFactoryMissingException.kt deleted file mode 100644 index 49d53df..0000000 --- a/core/src/main/kotlin/fr/xgouchet/elmyr/ForgeryFactoryMissingException.kt +++ /dev/null @@ -1,11 +0,0 @@ -package fr.xgouchet.elmyr - -/** - * Raised by a [Forge] when asked to forge an instance with no compatible [ForgeryFactory]. - * - * @param clazz the class that was being forged - */ -class ForgeryFactoryMissingException( - clazz: Class<*> -) : ForgeryException("Cannot create forgery for type ${clazz.canonicalName}.\n" + - "Make sure you provide a factory for this type.") diff --git a/core/src/main/kotlin/fr/xgouchet/elmyr/ReflexiveFactory.kt b/core/src/main/kotlin/fr/xgouchet/elmyr/ReflexiveFactory.kt new file mode 100644 index 0000000..11500fa --- /dev/null +++ b/core/src/main/kotlin/fr/xgouchet/elmyr/ReflexiveFactory.kt @@ -0,0 +1,74 @@ +package fr.xgouchet.elmyr + +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.KTypeProjection +import kotlin.reflect.full.valueParameters + +internal class ReflexiveFactory( + private val forge: Forge +) { + + fun getForgery(kClass: KClass): T { + if (kClass.isData) { + return getDataClassForgery(kClass) + } else { + throw ReflexiveForgeryFactoryException(kClass.java, "only data classes are supported") + } + } + + private fun getDataClassForgery(kClass: KClass): T { + val constructor = kClass.constructors.first() + val parameters = constructor.valueParameters + val arguments = Array(parameters.size) { null } + + parameters.forEachIndexed { idx, param -> + arguments[idx] = forgeType(param.type, kClass.java) + } + return constructor.call(*arguments) + } + + private fun forgeType(type: KType, fromClass: Class<*>): Any? { + val typeClassifier = type.classifier + + return when (typeClassifier) { + Boolean::class -> forge.aBool() + Int::class -> forge.anInt() + Long::class -> forge.aLong() + Float::class -> forge.aFloat() + Double::class -> forge.aDouble() + String::class -> forge.aString() + java.util.List::class -> forgeList(type.arguments, fromClass) + java.util.Map::class -> forgeMap(type.arguments, fromClass) + java.util.Set::class -> forgeList(type.arguments, fromClass).toSet() + is KClass<*> -> getForgery(typeClassifier) + else -> throw ReflexiveForgeryFactoryException(fromClass, "Unknown parameter type $type") + } + } + + private fun forgeList(arguments: List, fromClass: Class<*>): List { + if (arguments.size != 1) { + throw ReflexiveForgeryFactoryException(fromClass, "wrong number of type arguments for List") + } + + val itemType = arguments.first().type + if (itemType != null) { + return forge.aList { forgeType(itemType, fromClass) } + } else { + throw ReflexiveForgeryFactoryException(fromClass, "unknown type argument for List") + } + } + + private fun forgeMap(arguments: List, fromClass: Class<*>): Map { + if (arguments.size != 2) { + throw ReflexiveForgeryFactoryException(fromClass, "wrong number of type arguments for Map") + } + val keyType = arguments[0].type + val valueType = arguments[1].type + if (keyType is KType && valueType is KType) { + return forge.aMap { forgeType(keyType, fromClass) to forgeType(valueType, fromClass) } + } else { + throw ReflexiveForgeryFactoryException(fromClass, "unknown key or value type argument for Map") + } + } +} diff --git a/core/src/test/kotlin/fr/xgouchet/elmyr/ReflexiveFactorySpek.kt b/core/src/test/kotlin/fr/xgouchet/elmyr/ReflexiveFactorySpek.kt new file mode 100644 index 0000000..784b90c --- /dev/null +++ b/core/src/test/kotlin/fr/xgouchet/elmyr/ReflexiveFactorySpek.kt @@ -0,0 +1,78 @@ +package fr.xgouchet.elmyr + +import org.assertj.core.api.Assertions.assertThat +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.specification.describe + +class ReflexiveFactorySpek : Spek({ + + describe("A forge") { + val forge = Forge() + var seed: Long + + beforeEachTest { + seed = Forge.seed() + forge.seed = seed + } + + context("forging unknown data class ") { + + it("cannot forge a non data class") { + try { + forge.getForgery() + throw AssertionError("Should fail here") + } catch (e: ForgeryException) { + // Nothing to do here + } + } + + it("forges data class instance with primitive fields") { + val withPrimitiveFields = forge.getForgery() + + assertThat(withPrimitiveFields.anInt).isNotZero() + assertThat(withPrimitiveFields.aLong).isNotZero() + assertThat(withPrimitiveFields.aFloat).isNotZero() + assertThat(withPrimitiveFields.aDouble).isNotZero() + assertThat(withPrimitiveFields.aString).isNotEmpty() + } + + it("forges data class instance with data class fields") { + val withDataClassField = forge.getForgery() + + val withPrimitiveFields = withDataClassField.field + assertThat(withPrimitiveFields.anInt).isNotZero() + assertThat(withPrimitiveFields.aLong).isNotZero() + assertThat(withPrimitiveFields.aFloat).isNotZero() + assertThat(withPrimitiveFields.aDouble).isNotZero() + assertThat(withPrimitiveFields.aString).isNotEmpty() + } + + it("forges data class instance with collection fields") { + val withCollectionFields = forge.getForgery() + + assertThat(withCollectionFields.floatList).isNotEmpty() + assertThat(withCollectionFields.stringToLongMap).isNotEmpty() + assertThat(withCollectionFields.dataClassSet).isNotEmpty() + } + } + } +}) + +class NotADataClass(val i: Int) + +data class WithPrimitiveFields( + val aBool: Boolean, + val anInt: Int, + val aLong: Long, + val aFloat: Float, + val aDouble: Double, + val aString: String +) + +data class WithDataClassField(val field: WithPrimitiveFields) + +data class WithCollectionFields( + val floatList: List, + val stringToLongMap: Map, + val dataClassSet: Set +) diff --git a/core/src/test/kotlin/fr/xgouchet/elmyr/regex/LRUCacheSpek.kt b/core/src/test/kotlin/fr/xgouchet/elmyr/regex/LRUCacheSpek.kt index 4cf9afe..b3ce37f 100644 --- a/core/src/test/kotlin/fr/xgouchet/elmyr/regex/LRUCacheSpek.kt +++ b/core/src/test/kotlin/fr/xgouchet/elmyr/regex/LRUCacheSpek.kt @@ -2,6 +2,7 @@ package fr.xgouchet.elmyr.regex import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.throws +import java.util.Locale import org.assertj.core.api.Assertions.assertThat import org.spekframework.spek2.Spek import org.spekframework.spek2.style.specification.describe @@ -38,7 +39,7 @@ class LRUCacheSpek : Spek({ factoryCalls = 0 cache = LRUCache(capacity) { key -> factoryCalls++ - key.reversed().toLowerCase() + key.reversed().lowercase(Locale.getDefault()) } } @@ -48,7 +49,7 @@ class LRUCacheSpek : Spek({ val value = cache.get(key) assertThat(factoryCalls).isEqualTo(1) - assertThat(value).isEqualTo(key.reversed().toLowerCase()) + assertThat(value).isEqualTo(key.reversed().lowercase(Locale.getDefault())) } it("it returns cached value on second call") { @@ -59,7 +60,7 @@ class LRUCacheSpek : Spek({ val value2 = cache.get(key) assertThat(factoryCalls).isEqualTo(0) - assertThat(value).isEqualTo(key.reversed().toLowerCase()) + assertThat(value).isEqualTo(key.reversed().lowercase(Locale.getDefault())) assertThat(value2).isEqualTo(value) } @@ -74,7 +75,7 @@ class LRUCacheSpek : Spek({ val value2 = cache.get(key) assertThat(factoryCalls).isEqualTo(0) - assertThat(value).isEqualTo(key.reversed().toLowerCase()) + assertThat(value).isEqualTo(key.reversed().lowercase(Locale.getDefault())) assertThat(value2).isEqualTo(value) } @@ -91,7 +92,7 @@ class LRUCacheSpek : Spek({ val value2 = cache.get(key) assertThat(factoryCalls).isEqualTo(1) - assertThat(value).isEqualTo(key.reversed().toLowerCase()) + assertThat(value).isEqualTo(key.reversed().lowercase(Locale.getDefault())) assertThat(value2).isEqualTo(value) } }