All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.com.copperleaf.ballast.test.internal.ViewModelTestSuiteScopeImpl.kt Maven / Gradle / Ivy

There is a newer version: 4.2.1
Show newest version
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