Skip to content

Commit

Permalink
Create a reflexive Forgery Factory
Browse files Browse the repository at this point in the history
  • Loading branch information
xgouchet committed Sep 26, 2023
1 parent 64da4e0 commit 00d34f0
Show file tree
Hide file tree
Showing 7 changed files with 213 additions and 33 deletions.
1 change: 1 addition & 0 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ plugins {

dependencies {
implementation(libs.kotlin)
implementation(libs.kotlinReflect)

testImplementation(libs.bundles.junit5)
testImplementation(libs.bundles.spek)
Expand Down
45 changes: 28 additions & 17 deletions core/src/main/kotlin/fr/xgouchet/elmyr/Forge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -18,6 +19,7 @@ open class Forge {
private val rng = Random()

private val factories: MutableMap<Class<*>, ForgeryFactory<*>> = mutableMapOf()
private val reflexiveFactory by lazy { ReflexiveFactory(this) }

// region Reproducibility

Expand Down Expand Up @@ -61,39 +63,48 @@ open class Forge {
* @throws [IllegalArgumentException] if no compatible factory exists
*/
inline fun <reified T : Any> 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 <T : Any> getForgery(clazz: Class<T>): T {

@Suppress("UNCHECKED_CAST")
val strictMatch = factories[clazz] as? ForgeryFactory<T>

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<T>).getForgery(this)
else -> throw ForgeryFactoryMissingException(clazz = clazz)
}
}

private fun <T : Any> getSubclassForgery(clazz: Class<T>): 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 <T : Any> getForgery(kClass: KClass<T>): T {
val jClass = kClass.java
val strictMatch = factories[jClass] as? ForgeryFactory<T>
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<T>).getForgery(this)
else -> reflexiveFactory.getForgery(kClass)
}
val factory = anElementFrom(matches.toList())
@Suppress("UNCHECKED_CAST")
return (factory as ForgeryFactory<T>).getForgery(this)
}

// endregion
Expand Down
26 changes: 26 additions & 0 deletions core/src/main/kotlin/fr/xgouchet/elmyr/ForgeryException.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)

This file was deleted.

74 changes: 74 additions & 0 deletions core/src/main/kotlin/fr/xgouchet/elmyr/ReflexiveFactory.kt
Original file line number Diff line number Diff line change
@@ -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 <T : Any> getForgery(kClass: KClass<T>): T {
if (kClass.isData) {
return getDataClassForgery(kClass)
} else {
throw ReflexiveForgeryFactoryException(kClass.java, "only data classes are supported")
}
}

private fun <T : Any> getDataClassForgery(kClass: KClass<T>): T {
val constructor = kClass.constructors.first()
val parameters = constructor.valueParameters
val arguments = Array<Any?>(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<KTypeProjection>, fromClass: Class<*>): List<Any?> {
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<KTypeProjection>, fromClass: Class<*>): Map<Any?, Any?> {
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")
}
}
}
78 changes: 78 additions & 0 deletions core/src/test/kotlin/fr/xgouchet/elmyr/ReflexiveFactorySpek.kt
Original file line number Diff line number Diff line change
@@ -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<NotADataClass>()
throw AssertionError("Should fail here")
} catch (e: ForgeryException) {
// Nothing to do here
}
}

it("forges data class instance with primitive fields") {
val withPrimitiveFields = forge.getForgery<WithPrimitiveFields>()

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<WithDataClassField>()

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<WithCollectionFields>()

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<Float>,
val stringToLongMap: Map<String, Long>,
val dataClassSet: Set<WithPrimitiveFields>
)
11 changes: 6 additions & 5 deletions core/src/test/kotlin/fr/xgouchet/elmyr/regex/LRUCacheSpek.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -38,7 +39,7 @@ class LRUCacheSpek : Spek({
factoryCalls = 0
cache = LRUCache(capacity) { key ->
factoryCalls++
key.reversed().toLowerCase()
key.reversed().lowercase(Locale.getDefault())
}
}

Expand All @@ -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") {
Expand All @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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)
}
}
Expand Down

0 comments on commit 00d34f0

Please sign in to comment.