Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix verifier in contain exactly in any order draft (#59) #3976

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -1,7 +1,9 @@
package io.kotest.matchers.collections

import io.kotest.assertions.print.print
import io.kotest.equals.CommutativeEquality
import io.kotest.equals.Equality
import io.kotest.equals.countByEquality
import io.kotest.matchers.Matcher
import io.kotest.matchers.MatcherResult
import io.kotest.matchers.neverNullMatcher
Expand Down Expand Up @@ -115,21 +117,17 @@ fun <T, C : Collection<T>> containExactlyInAnyOrder(
verifier: Equality<T>?,
): Matcher<C?> = neverNullMatcher { actual ->

val valueGroupedCounts: Map<T, Int> = actual.groupBy { it }.mapValues { it.value.size }
val expectedGroupedCounts: Map<T, Int> = expected.groupBy { it }.mapValues { it.value.size }

val passed = expectedGroupedCounts.size == valueGroupedCounts.size
&& expectedGroupedCounts.all { (k, v) ->
valueGroupedCounts.filterKeys { verifier?.verify(k, it)?.areEqual() ?: (k == it) }[k] == v
}
val valueGroupedCounts: Map<T, Int> = getGroupedCount(actual, verifier)
val expectedGroupedCounts: Map<T, Int> = getGroupedCount(expected, verifier)

val missing = expected.filterNot { t ->
actual.any { verifier?.verify(it, t)?.areEqual() ?: (t == it) }
}
val extra = actual.filterNot { t ->
expected.any { verifier?.verify(it, t)?.areEqual() ?: (t == it) }
}
val countMismatch = countMismatch(expectedGroupedCounts, valueGroupedCounts)
val countMismatch = countMismatch(expectedGroupedCounts, valueGroupedCounts, verifier)
val passed = missing.isEmpty() && extra.isEmpty() && countMismatch.isEmpty()

val failureMessage = {
buildString {
Expand All @@ -154,14 +152,40 @@ fun <T, C : Collection<T>> containExactlyInAnyOrder(
)
}

internal fun<T> countMismatch(expectedCounts: Map<T, Int>, actualCounts: Map<T, Int>) =
actualCounts.entries.mapNotNull { actualEntry ->
expectedCounts[actualEntry.key]?.let { expectedValue ->
if(actualEntry.value != expectedValue)
private fun <C : Collection<T>, T> getGroupedCount(actual: C, verifier: Equality<T>?) =
if(verifier == null) {
actual.groupBy { it }.mapValues { it.value.size }
} else {
actual.countByEquality(verifier)
}

internal fun<T> countMismatch(
expectedCounts: Map<T, Int>,
actualCounts: Map<T, Int>,
verifier: Equality<T>?
): List<CountMismatch<T>> {
if(verifier == null) {
return actualCounts.entries.mapNotNull { actualEntry ->
expectedCounts[actualEntry.key]?.let { expectedValue ->
if (actualEntry.value != expectedValue)
CountMismatch(actualEntry.key, expectedValue, actualEntry.value)
else null
}
}
}
val commutativeVerifier = CommutativeEquality(verifier)
return actualCounts.entries.mapNotNull { actualEntry ->
val equalKeyInExpected =
expectedCounts.keys.firstOrNull { expectedKey ->
commutativeVerifier.verify(expectedKey, actualEntry.key).areEqual()
} ?: actualEntry.key
expectedCounts[equalKeyInExpected]?.let { expectedValue ->
if (actualEntry.value != expectedValue)
CountMismatch(actualEntry.key, expectedValue, actualEntry.value)
else null
}
}
}

internal data class CountMismatch<T>(val key: T, val expectedCount: Int, val actualCount: Int) {
init {
Expand Down
Expand Up @@ -3,6 +3,8 @@ package com.sksamuel.kotest.matchers.collections
import io.kotest.assertions.shouldFailWithMessage
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.WordSpec
import io.kotest.equals.Equality
import io.kotest.equals.EqualityResult
import io.kotest.matchers.collections.CountMismatch
import io.kotest.matchers.collections.containExactly
import io.kotest.matchers.collections.containExactlyInAnyOrder
Expand Down Expand Up @@ -365,25 +367,41 @@ class ShouldContainExactlyTest : WordSpec() {
it shouldContainExactlyInAnyOrder listOf("1", "2", "3", "4", "5", "6", "7")
}
}

"use custom verifier correctly" {
val caseInsensitiveStringEquality: Equality<String> = object : Equality<String> {
override fun name() = "Case Insensitive String Matcher"

override fun verify(actual: String, expected: String): EqualityResult {
return if(actual.uppercase() == expected.uppercase())
EqualityResult.equal(actual, expected, this)
else
EqualityResult.notEqual(actual, expected, this)
}
}
listOf("apple", "orange", "Apple") should containExactlyInAnyOrder(listOf("APPLE", "APPLE", "Orange"), caseInsensitiveStringEquality)
}
}

"countMismatch" should {
"return empty list for a complete match" {
val counts = mapOf("apple" to 1, "orange" to 2)
countMismatch(counts, counts).shouldBeEmpty()
countMismatch(counts, counts, Equality.default()).shouldBeEmpty()
}
"return differences for not null key" {
countMismatch(
mapOf("apple" to 1, "orange" to 2, "banana" to 3),
mapOf("apple" to 2, "orange" to 2, "peach" to 1)
mapOf("apple" to 2, "orange" to 2, "peach" to 1),
Equality.default()
) shouldBe listOf(
CountMismatch("apple", 1, 2)
)
}
"return differences for null key" {
countMismatch(
mapOf(null to 1, "orange" to 2, "banana" to 3),
mapOf(null to 2, "orange" to 2, "peach" to 1)
mapOf(null to 2, "orange" to 2, "peach" to 1),
Equality.default()
) shouldBe listOf(
CountMismatch(null, 1, 2)
)
Expand Down
Expand Up @@ -2835,6 +2835,12 @@ public final class io/kotest/data/blocking/ForAll7Kt {
public static final fun forNone ([Lio/kotest/data/Row7;Lkotlin/jvm/functions/Function7;)V
}

public final class io/kotest/equals/CommutativeEquality : io/kotest/equals/Equality {
public fun <init> (Lio/kotest/equals/Equality;)V
public fun name ()Ljava/lang/String;
public fun verify (Ljava/lang/Object;Ljava/lang/Object;)Lio/kotest/equals/EqualityResult;
}

public abstract interface class io/kotest/equals/Equality {
public static final field Companion Lio/kotest/equals/Equality$Companion;
public abstract fun name ()Ljava/lang/String;
Expand Down Expand Up @@ -2869,6 +2875,10 @@ public final class io/kotest/equals/EqualityResultKt {
public static final fun areNotEqual (Lio/kotest/equals/EqualityResult;)Z
}

public final class io/kotest/equals/GroupByEqualityKt {
public static final fun countByEquality (Ljava/lang/Iterable;Lio/kotest/equals/Equality;)Ljava/util/Map;
}

public final class io/kotest/equals/SimpleEqualityResult : io/kotest/equals/EqualityResult {
public fun <init> (ZLio/kotest/equals/EqualityResultDetails;)V
public fun areEqual ()Z
Expand Down
@@ -0,0 +1,29 @@
package io.kotest.equals

class CommutativeEquality<T: Any?>(
private val equality: Equality<T>
): Equality<T> {
override fun name() = equality.name()

override fun verify(actual: T, expected: T): EqualityResult {
val result = equality.verify(actual, expected)
val resultInReverse = equality.verify(expected, actual)
return when {
result.areEqual() == resultInReverse.areEqual() -> result
else -> SimpleEqualityResult(
equal = false,
detailsValue = SimpleEqualityResultDetail(
explainFn = {
"""
|Non-commutative comparison
| Actual vs expected: ${resultDescription(result.areEqual())}, ${result.details().explain()}
| Expected vs actual: ${resultDescription(resultInReverse.areEqual())}, ${resultInReverse.details().explain()}
""".trimMargin()
}
)
)
}
}

private fun resultDescription(equal: Boolean) = if(equal) "passed" else "failed"
}
@@ -0,0 +1,18 @@
package io.kotest.equals

fun<T> Iterable<T>.countByEquality(equality: Equality<T>): Map<T, Int> {
val counts = mutableMapOf<T, Int>()
val commutativeEquality = CommutativeEquality(equality)
for(element in this) {
val equalElement = counts.keys.firstOrNull {
commutativeEquality.verify(element, it).areEqual()
}
if(equalElement != null) {
counts[equalElement] = (counts[equalElement]!! + 1)
} else {
counts[element] = 1
}
}
return counts.toMap()
}

@@ -0,0 +1,42 @@
package io.kotest.equals

import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldStartWith

class CommutativeEqualityTest: StringSpec() {
init {
"verify true if commutative and both matches true" {
val actual = CommutativeEquality<Int>(Equality.default()).verify(1, 1)
actual.areEqual() shouldBe true
}
"verify false if commutative and both matches false" {
val actual = CommutativeEquality<Int>(Equality.default()).verify(1, 2)
actual.areEqual() shouldBe false
}
"verify false if non-commutative" {
val nonCommutativeEquality = object: Equality<Int> {
override fun name() = "actual should be 0"

override fun verify(actual: Int, expected: Int): EqualityResult =
SimpleEqualityResult(
equal = (actual == 0),
detailsValue = SimpleEqualityResultDetail(
explainFn = {
if(actual == 0) "actual == 0" else "actual != 0"
}
)
)
}
val firstIsZero = CommutativeEquality(nonCommutativeEquality).verify(0, 1)
val firstIsNotZero = CommutativeEquality(nonCommutativeEquality).verify(1, 0)
assertSoftly {
firstIsZero.areEqual() shouldBe false
firstIsNotZero.areEqual() shouldBe false
firstIsZero.details().explain() shouldStartWith "Non-commutative comparison"
firstIsNotZero.details().explain() shouldStartWith "Non-commutative comparison"
}
}
}
}
@@ -0,0 +1,30 @@
package io.kotest.equals

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe

class CountByEqualityTest: StringSpec() {
private val caseInsensitiveStringEquality: Equality<String> = object : Equality<String> {
override fun name() = "Case Insensitive String Matcher"

override fun verify(actual: String, expected: String): EqualityResult {
return if(actual.uppercase() == expected.uppercase())
EqualityResult.equal(actual, expected, this)
else
EqualityResult.notEqual(actual, expected, this)
}
}

init {
"handle empty list" {
listOf<String>().countByEquality(caseInsensitiveStringEquality) shouldBe mapOf()
}
"handle one element" {
listOf<String>("apple").countByEquality(caseInsensitiveStringEquality) shouldBe mapOf("apple" to 1)
}
"handle multiple elements" {
listOf<String>("apple", "Orange", "Apple", "ORANGE", "APPLE").countByEquality(caseInsensitiveStringEquality) shouldBe
mapOf("apple" to 3, "Orange" to 2)
}
}
}