commonMain.com.copperleaf.ballast.test.internal.ViewModelTestSuiteScopeImpl.kt Maven / Gradle / Ivy
package com.copperleaf.ballast.test.internal
import com.copperleaf.ballast.EventHandler
import com.copperleaf.ballast.InputFilter
import com.copperleaf.ballast.InputHandler
import com.copperleaf.ballast.InputStrategy
import com.copperleaf.ballast.core.LifoInputStrategy
import com.copperleaf.ballast.test.ViewModelTestScenarioScope
import com.copperleaf.ballast.test.ViewModelTestSuiteScope
import com.copperleaf.ballast.test.internal.vm.TestEventHandler
import com.copperleaf.ballast.test.internal.vm.TestInputFilter
import com.copperleaf.ballast.test.internal.vm.TestInterceptor
import com.copperleaf.ballast.test.internal.vm.TestViewModel
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import kotlin.time.measureTime
@ExperimentalCoroutinesApi
@ExperimentalTime
internal class ViewModelTestSuiteScopeImpl(
private val inputHandler: InputHandler,
private val eventHandler: EventHandler,
private val filter: InputFilter?,
) : ViewModelTestSuiteScope {
private var suiteLogger: (String) -> Unit = { }
private var defaultTimeout: Duration = Duration.seconds(30)
private var inputStrategy: InputStrategy = LifoInputStrategy()
private var defaultInitialStateBlock: (() -> State)? = null
private val scenarioBlocks = mutableListOf>()
override fun logger(block: (String) -> Unit) {
this.suiteLogger = block
}
override fun defaultTimeout(timeout: () -> Duration) {
this.defaultTimeout = timeout()
}
override fun defaultInputStrategy(inputStrategy: () -> InputStrategy) {
this.inputStrategy = inputStrategy()
}
override fun defaultInitialState(block: () -> State) {
defaultInitialStateBlock = block
}
override fun scenario(name: String, block: ViewModelTestScenarioScope.() -> Unit) {
scenarioBlocks += ViewModelTestScenarioScopeImpl(name).apply(block)
}
private suspend fun runScenario(scenario: ViewModelTestScenarioScopeImpl) = supervisorScope {
val scenarioLogger = scenario.logger ?: suiteLogger
val scenarioTimeout = scenario.timeout ?: defaultTimeout
val scenarioInputStrategy = scenario.inputStrategy ?: inputStrategy
scenarioLogger("Scenario '${scenario.name}'")
scenarioLogger("before runScenario")
val testViewModel = TestViewModel(
logger = scenarioLogger,
interceptor = TestInterceptor(),
initialState = scenario.givenBlock?.invoke()
?: defaultInitialStateBlock?.invoke()
?: error("No initial state given"),
inputHandler = inputHandler,
filter = filter?.let { TestInputFilter(it) },
inputStrategy = scenarioInputStrategy,
)
// start running the VM in the background
val viewModelJob = launch(start = CoroutineStart.UNDISPATCHED) {
testViewModel.impl.start(this)
}
val eventHandlerJob = launch(start = CoroutineStart.UNDISPATCHED) {
testViewModel.impl.attachEventHandler(TestEventHandler(eventHandler))
}
val inputSequenceScope = ViewModelTestScenarioInputSequenceScopeImpl(scenarioLogger, testViewModel)
// run the scenario input sequence
scenarioLogger(" before onInputSequenceBlock")
scenario.onInputSequenceBlock(inputSequenceScope)
scenarioLogger(" after onInputSequenceBlock")
scenarioLogger(" before awaitSideEffectsCompletion")
withTimeoutOrNull(scenarioTimeout) {
testViewModel.impl.awaitSideEffectsCompletion()
}
scenarioLogger(" after awaitSideEffectsCompletion")
// await test completion
scenarioLogger(" before completing whole test")
inputSequenceScope.finish()
scenarioLogger(" after completing whole test")
// if the test passed, manually clear everything
scenarioLogger(" before cleanup")
viewModelJob.cancelAndJoin()
eventHandlerJob.cancelAndJoin()
testViewModel.onCleared()
scenarioLogger(" after cleanup")
// make assertions on the VM. Errors should get captured and thrown by this coroutine scope, cancelling
// everything if there are failures
scenarioLogger(" before verification")
scenario.verifyBlock(testViewModel.interceptor.getResults())
scenarioLogger(" after verification")
scenarioLogger("after runScenario")
}
private sealed class ScenarioResult {
abstract val scenario: ViewModelTestScenarioScopeImpl
abstract fun printResults(): String
data class Passed(
override val scenario: ViewModelTestScenarioScopeImpl,
val time: Duration
) : ScenarioResult() {
override fun printResults(): String {
return "Scenario '${scenario.name}': Passed ($time)"
}
}
data class Failed(
override val scenario: ViewModelTestScenarioScopeImpl,
val time: Duration,
val reason: Throwable,
) : ScenarioResult() {
override fun printResults(): String {
return "Scenario '${scenario.name}': Failed ($time)"
}
}
}
internal suspend fun runTest() = supervisorScope {
val totalTestTime = measureTime {
val results: List> = scenarioBlocks.map { scenario ->
async {
val result: Result
val scenarioTestTime = measureTime {
result = runCatching { runScenario(scenario) }
}
result.fold(
onSuccess = { ScenarioResult.Passed(scenario, scenarioTestTime) },
onFailure = { ScenarioResult.Failed(scenario, scenarioTestTime, it) },
)
}
}.awaitAll()
results.forEach {
val scenarioLogger = it.scenario.logger ?: suiteLogger
scenarioLogger(it.printResults())
}
results.filterIsInstance>().firstOrNull()?.let {
throw it.reason
}
}
suiteLogger("All scenarios completed in $totalTestTime")
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy