Skip to content

Commit

Permalink
2149 no experimental eventually (#2242)
Browse files Browse the repository at this point in the history
  • Loading branch information
jschneidereit committed May 18, 2021
1 parent 04962fb commit 90ac7a8
Show file tree
Hide file tree
Showing 8 changed files with 1,230 additions and 0 deletions.
@@ -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)


@@ -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
@@ -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

0 comments on commit 90ac7a8

Please sign in to comment.