Skip to content

Commit

Permalink
Fix verifier in contain exactly in any order draft (#59)
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexCue987 committed Apr 19, 2024
1 parent b612160 commit 3026fce
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 15 deletions.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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()
}

Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}

0 comments on commit 3026fce

Please sign in to comment.