Skip to content

Commit

Permalink
Merge branch 'master' into refactor-concurrency
Browse files Browse the repository at this point in the history
  • Loading branch information
sksamuel committed Aug 31, 2023
2 parents 9b5799d + 5401405 commit 709d8b1
Show file tree
Hide file tree
Showing 9 changed files with 938 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,55 @@ public final class io/kotest/matchers/doubles/ZeroKt {
public static final fun shouldNotBeZero (D)D
}

public final class io/kotest/matchers/equality/CompareKt {
public static final fun compareUsingFields (Ljava/lang/Object;Ljava/lang/Object;Lio/kotest/matchers/equality/FieldEqualityConfig;)Lio/kotest/matchers/equality/CompareResult;
}

public final class io/kotest/matchers/equality/CompareResult {
public static final field Companion Lio/kotest/matchers/equality/CompareResult$Companion;
public fun <init> (Ljava/util/List;Ljava/util/Map;)V
public final fun component1 ()Ljava/util/List;
public final fun component2 ()Ljava/util/Map;
public final fun copy (Ljava/util/List;Ljava/util/Map;)Lio/kotest/matchers/equality/CompareResult;
public static synthetic fun copy$default (Lio/kotest/matchers/equality/CompareResult;Ljava/util/List;Ljava/util/Map;ILjava/lang/Object;)Lio/kotest/matchers/equality/CompareResult;
public fun equals (Ljava/lang/Object;)Z
public final fun getErrors ()Ljava/util/Map;
public final fun getFields ()Ljava/util/List;
public fun hashCode ()I
public final fun reduce (Lio/kotest/matchers/equality/CompareResult;)Lio/kotest/matchers/equality/CompareResult;
public fun toString ()Ljava/lang/String;
public final fun withError (Ljava/lang/String;Ljava/lang/Throwable;)Lio/kotest/matchers/equality/CompareResult;
public final fun withMatch (Ljava/lang/String;)Lio/kotest/matchers/equality/CompareResult;
}

public final class io/kotest/matchers/equality/CompareResult$Companion {
public final fun getEmpty ()Lio/kotest/matchers/equality/CompareResult;
public final fun match (Ljava/lang/String;)Lio/kotest/matchers/equality/CompareResult;
public final fun single (Ljava/lang/String;Ljava/lang/Throwable;)Lio/kotest/matchers/equality/CompareResult;
}

public final class io/kotest/matchers/equality/EqualToComparingFieldsKt {
public static final fun beEqualUsingFields (Ljava/lang/Object;Lio/kotest/matchers/equality/FieldEqualityConfig;)Lio/kotest/matchers/Matcher;
public static final fun shouldBeEqualUsingFields (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
public static final fun shouldBeEqualUsingFields (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
public static final fun shouldNotBeEqualUsingFields (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
public static final fun shouldNotBeEqualUsingFields (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
}

public final class io/kotest/matchers/equality/FieldEqualityConfig {
public fun <init> ()V
public final fun getExcludedProperties ()Ljava/util/Collection;
public final fun getIgnoreComputedFields ()Z
public final fun getIgnorePrivateFields ()Z
public final fun getIncludedProperties ()Ljava/util/Collection;
public final fun getUseDefaultShouldBeForFields ()Ljava/util/Collection;
public final fun setExcludedProperties (Ljava/util/Collection;)V
public final fun setIgnoreComputedFields (Z)V
public final fun setIgnorePrivateFields (Z)V
public final fun setIncludedProperties (Ljava/util/Collection;)V
public final fun setUseDefaultShouldBeForFields (Ljava/util/Collection;)V
}

public final class io/kotest/matchers/equality/FieldsEqualityCheckConfig {
public fun <init> ()V
public fun <init> (ZZLjava/util/List;Ljava/util/List;)V
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package io.kotest.matchers.equality

import io.kotest.assertions.eq.eq
import io.kotest.assertions.failure
import io.kotest.assertions.print.print
import io.kotest.mpp.bestName
import kotlin.reflect.KClass
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.jvm.jvmName

fun <T> compareUsingFields(
actual: T,
expected: T,
config: FieldEqualityConfig,
): CompareResult {
return when {
actual == null -> throw failure("Expected ${expected.print().value} but actual was null")
expected == null -> throw failure("Expected null but actual was ${actual.print().value}")
else -> compareFields(actual, expected, null, config)
}
}

private fun compareFields(actual: Any?, expected: Any?, field: String?, config: FieldEqualityConfig): CompareResult {

val props1 = actual.fields(config.predicates())
val props2 = expected.fields(config.predicates())

// types don't have to match but they should at least have the same fields
if (props1 != props2)
throw failure("Comparing type ${actual!!::class.jvmName} to ${expected!!::class.jvmName} with mismatched properties")

return props1.fold(CompareResult(emptyList(), emptyMap())) { acc, prop ->
println("Prop: " + prop.returnType.toString().replace("?", ""))
val actualValue = prop.getter.call(actual)
val expectedValue = prop.getter.call(expected)
val name = if (field == null) prop.name else field + "." + prop.name
val returnType = prop.returnType.classifier as KClass<*>
acc.reduce(compareValue(actualValue, expectedValue, returnType, name, config))
}
}

private fun compareValue(
actual: Any?,
expected: Any?,
type: KClass<*>,
field: String,
config: FieldEqualityConfig
): CompareResult {
println("Compare value $type from $actual $expected")

return when {
type.isSubclassOf(Collection::class) -> {
val actualCollection = actual as Collection<*>
val expectedCollection = expected as Collection<*>
compareCollections(actualCollection, expectedCollection, field, config)
}

type.isSubclassOf(Map::class) -> {
val actualMap = actual as Map<*, *>
val expectedMap = expected as Map<*, *>
compareMaps(actualMap, expectedMap, field, config)
}

useEq(
actual,
expected,
type,
config.useDefaultShouldBeForFields
) -> {
val throwable = eq(actual, expected)
if (throwable == null) CompareResult.match(field) else CompareResult.single(field, throwable)
}

else -> compareFields(actual, expected, field, config)
}
}

private fun compareCollections(
actual: Collection<*>,
expected: Collection<*>,
field: String,
config: FieldEqualityConfig
): CompareResult {

return if (actual.size != expected.size)
CompareResult.single(field, failure("Collections differ in size: ${actual.size} != ${expected.size}"))
else if (actual.isEmpty())
CompareResult.empty
else {
actual.zip(expected).withIndex().map { (index, value) ->
// other element should be an instance of this element or vice version
val elementName = "$field[$index]"
when {
value.first == null && value.second == null -> CompareResult.empty
value.first == null -> CompareResult.single(
elementName,
failure("Expected ${value.second.print().value} but actual was null")
)

value.second == null -> CompareResult.single(
elementName,
failure("Expected null but actual was ${value.first.print().value}")
)

else -> compareValue(value.first, value.second, value.first!!::class, elementName, config)
}
}.reduce { a, op -> a.reduce(op) }
}
}

private fun compareMaps(
actual: Map<*, *>,
expected: Map<*, *>,
field: String,
config: FieldEqualityConfig
): CompareResult {

return if (actual.size != expected.size)
CompareResult.single(field, failure("Maps differ in size: ${actual.size} != ${expected.size}"))
else if (actual.isEmpty())
CompareResult.empty
else {
actual.keys.map { key ->
val a = actual[key]
val b = expected[key]
compareValue(a, b, a!!::class, "$field[$key]", config)
}.reduce { a, op -> a.reduce(op) }
}
}


data class CompareResult(
val fields: List<String>,
val errors: Map<String, Throwable>,
) {

companion object {
val empty: CompareResult = CompareResult(emptyList(), emptyMap())
fun single(field: String, error: Throwable): CompareResult = CompareResult(listOf(field), mapOf(field to error))
fun match(field: String): CompareResult = CompareResult(listOf(field), emptyMap())
}

fun withMatch(field: String) = CompareResult(fields + field, errors)
fun withError(field: String, error: Throwable) = CompareResult(fields + field, errors + Pair(field, error))
fun reduce(other: CompareResult) =
CompareResult(this.fields + other.fields, this.errors + other.errors)
}

private val builtins = setOf("boolean", "byte", "double", "float", "int", "long", "short")

/**
* Returns true if we should use an instance of [Eq] for comparison, rather than field by field recursion.
*/
internal fun useEq(
actual: Any?,
expected: Any?,
typeName: KClass<*>,
useEqs: Collection<KClass<*>>,
): Boolean {
val expectedOrActualIsNull = actual == null || expected == null
val typeIsJavaOrKotlinBuiltIn by lazy {
val bestName = typeName.bestName()
bestName.startsWith("kotlin") ||
bestName.startsWith("java") ||
builtins.contains(bestName)
}
val expectedOrActualIsEnum = actual is Enum<*>
|| expected is Enum<*>
|| (actual != null && actual::class.java.isEnum)
|| (expected != null && expected::class.java.isEnum)
return expectedOrActualIsNull
|| typeIsJavaOrKotlinBuiltIn
|| useEqs.contains(typeName)
|| expectedOrActualIsEnum
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package io.kotest.matchers.equality

import io.kotest.assertions.print.print
import io.kotest.matchers.Matcher
import io.kotest.matchers.MatcherResult
import io.kotest.matchers.should
import io.kotest.matchers.shouldNot
import kotlin.reflect.KClass
import kotlin.reflect.KProperty

infix fun <T : Any> T.shouldBeEqualUsingFields(other: T): T {
val config = FieldEqualityConfig()
this should beEqualUsingFields(other, config)
return this
}

infix fun <T : Any> T.shouldNotBeEqualUsingFields(other: T): T {
val config = FieldEqualityConfig()
this shouldNot beEqualUsingFields(other, config)
return this
}

/**
* Matcher that compares values using field by field comparison.
*
* This matcher should be used to check equality of two class for which you want to consider their fields for equality
* instead of its `equals` method.
*
* This matcher recursively check equality of given values till we get a java class, kotlin class or fields for which we have
* specified to use default shouldBe. Once we get a java class, kotlin class or specified field the equality of that fields
* will be same as that we get with shouldBe matcher.
*
* @param block a configure block that can be use to configure the match, this must return the value to compare
*
* @see FieldsEqualityCheckConfig
*
* Example:
* ```
* package org.foo.bar.domain
*
* class ANestedClass(val name: String, val nestedField: AnotherNestedClass) {
* private val id = UUID.randomUUID()
* }
*
* class AnotherNestedClass(val buffer: Buffer) {
* val aComputedField: Int
* get() = Random.nextInt()
* }
*
* class SomeClass(val name: String, val randomId: UUID ,val nestedField: ANestedClass)
*
* someClass shouldBeEqualUsingFields {
*
* ignorePrivateFields = true
* ignoreComputedFields = true
* propertiesToExclude = listOf(SomeClass::randomId)
* useDefaultShouldBeForFields = listOf("org.foo.bar.domain.AnotherNestedClass")
*
* anotherInstanceOfSomeClass
* }
* ```
* */
infix fun <T : Any> T.shouldBeEqualUsingFields(block: FieldEqualityConfig.() -> T): T {
val config = FieldEqualityConfig()
val other = block.invoke(config)
this should beEqualUsingFields(other, config)
return this
}

infix fun <T : Any> T.shouldNotBeEqualUsingFields(block: FieldEqualityConfig.() -> T): T {
val config = FieldEqualityConfig()
val other = block.invoke(config)
this shouldNot beEqualUsingFields(other, config)
return this
}

/**
* Config for controlling the way shouldBeEqualUsingFields compares fields.
*
* Note: If both [includedProperties] and [excludedProperties] are not empty, an error will be thrown.
*
*
* @property ignorePrivateFields specify whether to exclude private fields in comparison. Default true.
* @property ignoreComputedFields specify whether to exclude computed fields in comparison. Default true.
* @property includedProperties specify which fields to include. Default empty list.
* @property excludedProperties specify which fields to exclude in comparison. Default emptyList.
* @property useDefaultShouldBeForFields data types for which to use the standard shouldBe comparision
* instead of recursive field by field comparison.
* */
class FieldEqualityConfig {
var ignorePrivateFields: Boolean = true
var ignoreComputedFields: Boolean = true
var includedProperties: Collection<KProperty<*>> = emptySet()
var excludedProperties: Collection<KProperty<*>> = emptySet()
var useDefaultShouldBeForFields: Collection<KClass<*>> = emptySet()
}

fun <T : Any> beEqualUsingFields(expected: T, config: FieldEqualityConfig): Matcher<T> {

if (config.includedProperties.isNotEmpty() && config.excludedProperties.isNotEmpty())
error("Cannot set both includedProperties and excludedProperties")

return object : Matcher<T> {
override fun test(value: T): MatcherResult {
return runCatching { compareUsingFields(value, expected, config) }.fold(
{ result ->
MatcherResult(
result.errors.isEmpty(),
{
"""Expected ${value.print().value} to equal ${expected.print().value}
|
|Using fields:
|${result.fields.joinToString("\n") { " - $it" }}
|
|Fields that differ:
|${result.errors.entries.joinToString("\n") { " - ${it.key} => ${it.value.message}" }}
|
""".trimMargin()
},
{
"""Expected ${value.print().value} to not equal ${expected.print().value}
|
|Using fields:
|${result.fields.joinToString("\n") { " - $it" }}
|
""".trimMargin()
}
)
},
{
MatcherResult(
false,
{
"""Error using shouldBeEqualUsingFields matcher
|${it.message}
""".trimMargin()
},
{
"""Error using shouldBeEqualUsingFields matcher
|${it.message}
""".trimMargin()
})
}
)
}
}
}
Loading

0 comments on commit 709d8b1

Please sign in to comment.