diff --git a/kotest-property/src/commonMain/kotlin/io/kotest/property/arbitrary/maps.kt b/kotest-property/src/commonMain/kotlin/io/kotest/property/arbitrary/maps.kt index c1f608dbc99..e44e4bc6b22 100644 --- a/kotest-property/src/commonMain/kotlin/io/kotest/property/arbitrary/maps.kt +++ b/kotest-property/src/commonMain/kotlin/io/kotest/property/arbitrary/maps.kt @@ -14,15 +14,37 @@ import io.kotest.property.Shrinker * removing elements from the failed case until it is empty. * * @see MapShrinker + * + * @param arb the arbitrary to populate the map entries + * @param minSize the desired minimum size of the generated map + * @param maxSize the desired maximum size of the generated map + * @param slippage when generating keys, we may have repeats if the underlying gen is random. + * The slippage factor determines how many times we continue after retrieving a duplicate key. + * The total acceptable number of misses is the slippage factor multiplied by the target set size. + * If this value is not specified, then the default slippage value of 10 will be used. */ fun Arb.Companion.map( arb: Arb>, minSize: Int = 1, - maxSize: Int = 100 -): Arb> = arbitrary(MapShrinker()) { random -> - val size = random.random.nextInt(minSize, maxSize) - val pairs = List(size) { arb.single(random) } - pairs.toMap() + maxSize: Int = 100, + slippage: Int = 10 +): Arb> = arbitrary(MapShrinker(minSize)) { random -> + val targetSize = random.random.nextInt(minSize, maxSize) + val maxMisses = targetSize * slippage + val map = mutableMapOf() + var iterations = 0 + while (iterations < maxMisses && map.size < targetSize) { + val initialSize = map.size + val (key, value) = arb.single(random) + map[key] = value + if (map.size == initialSize) iterations++ + } + + require(map.size >= minSize) { + "the minimum size requirement of $minSize could not be satisfied after $iterations consecutive samples" + } + + map } /** @@ -37,28 +59,47 @@ fun Arb.Companion.map( * * @see MapShrinker * + * @param keyArb the arbitrary to populate the keys + * @param valueArb the arbitrary to populate the values + * @param minSize the desired minimum size of the generated map + * @param maxSize the desired maximum size of the generated map + * @param slippage when generating keys, we may have repeats if the underlying gen is random. + * The slippage factor determines how many times we continue after retrieving a duplicate key. + * The total acceptable number of misses is the slippage factor multiplied by the target set size. + * If this value is not specified, then the default slippage value of 10 will be used. */ fun Arb.Companion.map( keyArb: Arb, valueArb: Arb, minSize: Int = 1, - maxSize: Int = 100 + maxSize: Int = 100, + slippage: Int = 10 ): Arb> { require(minSize >= 0) { "minSize must be positive" } require(maxSize >= 0) { "maxSize must be positive" } - return arbitrary(MapShrinker()) { random -> - val size = random.random.nextInt(minSize, maxSize) - val pairs = List(size) { - keyArb.single(random) to valueArb.single(random) + return arbitrary(MapShrinker(minSize)) { random -> + val targetSize = random.random.nextInt(minSize, maxSize) + val maxMisses = targetSize * slippage + val map = mutableMapOf() + var iterations = 0 + while (iterations < maxMisses && map.size < targetSize) { + val initialSize = map.size + map[keyArb.single(random)] = valueArb.single(random) + if (map.size == initialSize) iterations++ } - pairs.toMap() + + require(map.size >= minSize) { + "the minimum size requirement of $minSize could not be satisfied after $iterations consecutive samples" + } + + map } } -class MapShrinker : Shrinker> { +class MapShrinker(private val minSize: Int) : Shrinker> { override fun shrink(value: Map): List> { - return when (value.size) { + val shrinks = when (value.size) { 0 -> emptyList() 1 -> listOf(emptyMap()) else -> listOf( @@ -66,6 +107,8 @@ class MapShrinker : Shrinker> { value.toList().drop(1).toMap() ) } + + return shrinks.filter { it.size >= minSize } } } @@ -74,8 +117,8 @@ class MapShrinker : Shrinker> { * Edgecases will be derived from [k] and [v]. */ fun Arb.Companion.pair(k: Arb, v: Arb): Arb> { - val arbPairWithoutKeyEdges:Arb> = Arb.bind(k.removeEdgecases(), v, ::Pair) - val arbPairWithoutValueEdges:Arb> = Arb.bind(k, v.removeEdgecases(), ::Pair) + val arbPairWithoutKeyEdges: Arb> = Arb.bind(k.removeEdgecases(), v, ::Pair) + val arbPairWithoutValueEdges: Arb> = Arb.bind(k, v.removeEdgecases(), ::Pair) val arbPair: Arb> = Arb.bind(k, v, ::Pair) return Arb.choice(arbPair, arbPairWithoutKeyEdges, arbPairWithoutValueEdges) } diff --git a/kotest-property/src/jvmTest/kotlin/com/sksamuel/kotest/property/arbitrary/MapsTest.kt b/kotest-property/src/jvmTest/kotlin/com/sksamuel/kotest/property/arbitrary/MapsTest.kt index ea1bcd9507d..0e824e42b68 100644 --- a/kotest-property/src/jvmTest/kotlin/com/sksamuel/kotest/property/arbitrary/MapsTest.kt +++ b/kotest-property/src/jvmTest/kotlin/com/sksamuel/kotest/property/arbitrary/MapsTest.kt @@ -1,14 +1,19 @@ package com.sksamuel.kotest.property.arbitrary +import io.kotest.assertions.throwables.shouldThrowWithMessage import io.kotest.core.spec.style.FunSpec +import io.kotest.inspectors.forAll import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.ints.shouldBeInRange import io.kotest.property.Arb import io.kotest.property.RandomSource import io.kotest.property.arbitrary.Codepoint import io.kotest.property.arbitrary.alphanumeric import io.kotest.property.arbitrary.edgecases import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.map import io.kotest.property.arbitrary.pair +import io.kotest.property.arbitrary.single import io.kotest.property.arbitrary.string import io.kotest.property.arbitrary.take import io.kotest.property.arbitrary.withEdgecases @@ -43,4 +48,59 @@ class MapsTest : FunSpec({ ) } } + + context("Arb.map with individual key arb and value arb") { + test("should generate map of a specified size") { + val arbMap = Arb.map(Arb.int(1..10), Arb.string(1..10, Codepoint.alphanumeric()), minSize = 5, maxSize = 10) + val maps = arbMap.take(1000, RandomSource.seeded(12345L)).toList() + maps.forAll { it.size shouldBeInRange 5..10 } + } + + test("should produce shrinks that adhere to minimum size") { + val arbMap = Arb.map(Arb.int(1..10), Arb.string(1..10, Codepoint.alphanumeric()), minSize = 5, maxSize = 10) + val maps = arbMap.samples(RandomSource.seeded(12345L)).take(100).toList() + val shrinks = maps.flatMap { it.shrinks.children.value } + shrinks.forAll { it.value().size shouldBeInRange 5..10 } + } + + test("should throw when the cardinality of the key arbitrary does not satisfy the required minimum size") { + val arbKey = Arb.int(1..3) + val arbMap = Arb.map(arbKey, Arb.string(1..10), minSize = 5, maxSize = 10) + shouldThrowWithMessage( + "the minimum size requirement of 5 could not be satisfied after 90 consecutive samples" + ) { + arbMap.single(RandomSource.seeded(1234L)) + } + } + } + + + context("Arb.map with arb pair") { + test("should generate map of a specified size") { + val arbPair = Arb.pair(Arb.int(1..10), Arb.string(1..10, Codepoint.alphanumeric())) + val arbMap = Arb.map(arbPair, minSize = 5, maxSize = 10) + val maps = arbMap.take(100, RandomSource.seeded(12345L)).toList() + maps.forAll { it.size shouldBeInRange 5..10 } + } + + test("should produce shrinks that adhere to minimum size") { + val arbPair = Arb.pair(Arb.int(1..10), Arb.string(1..10, Codepoint.alphanumeric())) + val arbMap = Arb.map(arbPair, minSize = 5, maxSize = 10) + val maps = arbMap.samples(RandomSource.seeded(12345L)).take(100).toList() + val shrinks = maps.flatMap { it.shrinks.children.value } + shrinks.forAll { + it.value().size shouldBeInRange 5..10 + } + } + + test("should throw when the cardinality of the key arbitrary does not satisfy the required minimum size") { + val arbPair = Arb.pair(Arb.int(1..3), Arb.string(1..10, Codepoint.alphanumeric())) + val arbMap = Arb.map(arbPair, minSize = 5, maxSize = 10) + shouldThrowWithMessage( + "the minimum size requirement of 5 could not be satisfied after 90 consecutive samples" + ) { + arbMap.single(RandomSource.seeded(1234L)) + } + } + } })