-
Notifications
You must be signed in to change notification settings - Fork 623
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
2149 no experimental eventually (#2242)
- Loading branch information
1 parent
04962fb
commit 90ac7a8
Showing
8 changed files
with
1,230 additions
and
0 deletions.
There are no files selected for viewing
129 changes: 129 additions & 0 deletions
129
...t-framework-concurrency/src/commonMain/kotlin/io/kotest/framework/concurrency/Interval.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
package io.kotest.framework.concurrency | ||
|
||
import io.kotest.common.ExperimentalKotest | ||
import kotlin.math.pow | ||
import kotlin.time.Duration | ||
import kotlin.time.ExperimentalTime | ||
|
||
private const val hour = 3_600_000L | ||
|
||
/** | ||
* An [Interval] determines how often Kotest will invoke a predicate function for an [eventually], [until], or [continually] block. | ||
*/ | ||
@ExperimentalKotest | ||
interface Interval { | ||
|
||
/** | ||
* Returns the next delay as a [Millis]. | ||
* | ||
* @param count The number of times the condition has been polled (evaluated) so far. | ||
* Always a positive integer. | ||
* | ||
* @return The duration of the next poll interval | ||
*/ | ||
fun next(count: Int): Long | ||
} | ||
|
||
@ExperimentalKotest | ||
class FixedInterval(private val duration: Long) : Interval { | ||
override fun toString() = "FixedInterval(${::duration.name}=$duration)" | ||
|
||
override fun next(count: Int): Long { | ||
return duration | ||
} | ||
} | ||
|
||
@ExperimentalKotest | ||
fun Long.fixed(): FixedInterval = FixedInterval(this) | ||
|
||
@ExperimentalTime | ||
@ExperimentalKotest | ||
fun Duration.fixed() = this.toLongMilliseconds().fixed() | ||
|
||
|
||
/** | ||
* Fibonacci delay implements a delay where each duration is calculated as a multiplier | ||
* of the fibonacci sequence, 0, 1, 1, 2, 3, 5.... | ||
* | ||
* Some people start fib at 0, some at 1. | ||
* This implementation starts with 0 as per https://en.wikipedia.org/wiki/Fibonacci_number | ||
* | ||
* @param offset Added to the count, so if the offset is 4, then the first value will be the 4th fib number. | ||
* @param base The duration that is multiplied by the fibonacci value | ||
* @param max the maximum duration to clamp the resulting duration to defaults to [FibonacciInterval.defaultMax] | ||
*/ | ||
@ExperimentalKotest | ||
class FibonacciInterval(private val base: Long, private val offset: Int, private val max: Long?) : | ||
Interval { | ||
init { | ||
require(offset >= 0) { "Offset must be greater than or equal to 0" } | ||
} | ||
|
||
override fun toString() = | ||
"FibonacciInterval(${::base.name}=$base, ${::offset.name}=$offset, ${::max.name}=${max?.toString() ?: "null"})" | ||
|
||
override fun next(count: Int): Long { | ||
val total = base * fibonacci(offset + count) | ||
return if (max == null) total else minOf(max, total) | ||
} | ||
|
||
companion object { | ||
const val defaultMax: Long = hour * 2 | ||
} | ||
} | ||
|
||
@ExperimentalKotest | ||
fun Long.fibonacci(max: Long? = FibonacciInterval.defaultMax) = FibonacciInterval(this, 0, max) | ||
|
||
@ExperimentalTime | ||
@ExperimentalKotest | ||
fun Duration.fibonacci(max: Duration? = null) = this.toLongMilliseconds().fibonacci(max?.toLongMilliseconds() ?: FibonacciInterval.defaultMax) | ||
|
||
@ExperimentalKotest | ||
fun fibonacci(n: Int): Int { | ||
tailrec fun fib(k: Int, current: Int, previous: Int): Int = when (k) { | ||
0 -> previous | ||
1 -> current | ||
else -> fib(k - 1, current + previous, current) | ||
} | ||
return fib(n, 1, 0) | ||
} | ||
|
||
/** | ||
* Exponential interval implements a delay where each duration is calculated as a multiplier | ||
* of an exponent of the default [ExponentialInterval.defaultFactor] or a user specified factor. | ||
* | ||
* You should start at 0 to get the base value back and at 1 for the second value in the series, e.g.: | ||
* val interval = 2.seconds.exponential(max = Long.MAX_VALUE) which will produce 2s, 4s, 8s, etc. | ||
* | ||
* @param base the duration that is multiplied by the exponentiated factor | ||
* @param factor the factor to exponentiate by the current iteration value | ||
* @param max the maximum duration to clamp the resulting duration to defaults to [ExponentialInterval.defaultMax] | ||
*/ | ||
@ExperimentalKotest | ||
class ExponentialInterval(private val base: Long, private val factor: Double, private val max: Long?) : | ||
Interval { | ||
override fun toString() = "ExponentialInterval(${::base.name}=$base, ${::factor.name}=$factor, ${::max.name}=$max)" | ||
|
||
override fun next(count: Int): Long { | ||
val total = base * factor.pow(count) | ||
val duration = total.toLong() | ||
return if (max == null) duration else minOf(max, duration) | ||
} | ||
|
||
companion object { | ||
const val defaultMax: Long = hour * 2 | ||
const val defaultFactor = 2.0 | ||
} | ||
} | ||
|
||
@ExperimentalKotest | ||
fun Long.exponential(factor: Double = ExponentialInterval.defaultFactor, max: Long? = ExponentialInterval.defaultMax) = | ||
ExponentialInterval(this, factor, max) | ||
|
||
@ExperimentalTime | ||
@ExperimentalKotest | ||
fun Duration.exponential(factor: Double = ExponentialInterval.defaultFactor, max: Duration? = null) = | ||
this.toLongMilliseconds().exponential(factor, max?.toLongMilliseconds() ?: FibonacciInterval.defaultMax) | ||
|
||
|
5 changes: 5 additions & 0 deletions
5
...ramework-concurrency/src/commonMain/kotlin/io/kotest/framework/concurrency/concurrency.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
package io.kotest.framework.concurrency | ||
|
||
internal val defaultInterval = 25L.fixed() | ||
internal const val defaultDelay: Long = 0L | ||
internal const val defaultDuration: Long = 3_600_000L |
98 changes: 98 additions & 0 deletions
98
...ramework-concurrency/src/commonMain/kotlin/io/kotest/framework/concurrency/continually.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
package io.kotest.framework.concurrency | ||
|
||
import io.kotest.assertions.failure | ||
import io.kotest.common.ExperimentalKotest | ||
import io.kotest.mpp.timeInMillis | ||
import kotlinx.coroutines.delay | ||
import kotlin.time.Duration | ||
import kotlin.time.ExperimentalTime | ||
|
||
@OptIn(ExperimentalKotest::class) | ||
typealias ContinuallyListener<T> = (ContinuallyState<T>) -> Unit | ||
|
||
@ExperimentalKotest | ||
data class ContinuallyConfig<T>( | ||
val duration: Long = defaultDuration, | ||
val interval: Interval = defaultInterval, | ||
val initialDelay: Long = defaultDelay, | ||
val listener: ContinuallyListener<T>? = null, | ||
) | ||
|
||
@ExperimentalKotest | ||
class ContinuallyBuilder<T> { | ||
var duration: Long = defaultDuration | ||
var interval: Interval = defaultInterval | ||
var initialDelay: Long = defaultDelay | ||
var listener: ContinuallyListener<T>? = null | ||
|
||
fun toConfig() = ContinuallyConfig( | ||
duration = duration, interval = interval, initialDelay = initialDelay, listener = listener | ||
) | ||
|
||
constructor() | ||
|
||
constructor(config: ContinuallyConfig<T>) { | ||
duration = config.duration | ||
interval = config.interval | ||
initialDelay = config.initialDelay | ||
listener = config.listener | ||
} | ||
} | ||
|
||
@ExperimentalKotest | ||
data class ContinuallyState<T>(val result: T, val start: Long, val end: Long, val times: Int) | ||
|
||
@ExperimentalKotest | ||
private suspend fun <T> ContinuallyConfig<T>.invoke(f: suspend () -> T): T? { | ||
delay(initialDelay) | ||
|
||
val start = timeInMillis() | ||
val end = start + duration | ||
var times = 0 | ||
var result: T? = null | ||
|
||
while (timeInMillis() < end) { | ||
try { | ||
result = f() | ||
listener?.invoke(ContinuallyState(result, start, end, times)) | ||
} catch (e: AssertionError) { | ||
// if this is the first time the check was executed then just rethrow the underlying error | ||
if (times == 0) | ||
throw e | ||
// if not the first attempt then include how many times/for how long the test passed | ||
throw failure( | ||
"Test failed after $start; expected to pass for ${duration}; attempted $times times\nUnderlying failure was: ${e.message}", | ||
e | ||
) | ||
} | ||
delay(interval.next(++times)) | ||
} | ||
return result | ||
} | ||
|
||
// region continually | ||
|
||
@ExperimentalKotest | ||
suspend fun <T> continually( | ||
config: ContinuallyConfig<T>, configure: ContinuallyBuilder<T>.() -> Unit, @BuilderInference test: suspend () -> T | ||
): T? { | ||
val builder = ContinuallyBuilder(config).apply(configure) | ||
return builder.toConfig().invoke(test) | ||
} | ||
|
||
@ExperimentalKotest | ||
suspend fun <T> continually( | ||
configure: ContinuallyBuilder<T>.() -> Unit, @BuilderInference test: suspend () -> T | ||
): T? { | ||
val builder = ContinuallyBuilder<T>().apply(configure) | ||
return builder.toConfig().invoke(test) | ||
} | ||
|
||
@ExperimentalTime | ||
@ExperimentalKotest | ||
suspend fun <T> continually(duration: Duration, test: suspend () -> T): T? = continually(duration.toLongMilliseconds(), test) | ||
|
||
@ExperimentalKotest | ||
suspend fun <T> continually(duration: Long, test: suspend () -> T): T? = continually({ this.duration = duration }, test) | ||
|
||
// endregion |
Oops, something went wrong.