diff --git a/hello-vector/build.gradle b/hello-vector/build.gradle index bfd7575..46ad103 100644 --- a/hello-vector/build.gradle +++ b/hello-vector/build.gradle @@ -69,6 +69,7 @@ dependencies { implementation libs.constraintLayout implementation libs.vmSavedState + testImplementation project(path: ":vector-test") testImplementation libs.junit testImplementation libs.coroutinesTest testImplementation libs.mockk diff --git a/hello-vector/src/main/java/com/haroldadmin/hellovector/HelloState.kt b/hello-vector/src/main/java/com/haroldadmin/hellovector/HelloState.kt index 415b22c..4a4bfc5 100644 --- a/hello-vector/src/main/java/com/haroldadmin/hellovector/HelloState.kt +++ b/hello-vector/src/main/java/com/haroldadmin/hellovector/HelloState.kt @@ -6,9 +6,10 @@ import kotlinx.android.parcel.Parcelize @Parcelize data class HelloState( - val message: String = loadingMessage + val message: String = uninitializedMessage ) : VectorState, Parcelable { companion object { + const val uninitializedMessage = "..." const val loadingMessage = "Loading..." const val helloMessage = "Hello, World!" } diff --git a/hello-vector/src/main/java/com/haroldadmin/hellovector/HelloViewModel.kt b/hello-vector/src/main/java/com/haroldadmin/hellovector/HelloViewModel.kt index c619a05..bd93349 100644 --- a/hello-vector/src/main/java/com/haroldadmin/hellovector/HelloViewModel.kt +++ b/hello-vector/src/main/java/com/haroldadmin/hellovector/HelloViewModel.kt @@ -19,10 +19,6 @@ class HelloViewModel( const val defaultDelay = 1000L } - init { - getMessage() - } - fun getMessage(delayDuration: Long = defaultDelay) = viewModelScope.launch { setStateAndPersist { copy(message = HelloState.loadingMessage) } delay(delayDuration) diff --git a/hello-vector/src/test/java/com/haroldadmin/hellovector/HelloViewModelTest.kt b/hello-vector/src/test/java/com/haroldadmin/hellovector/HelloViewModelTest.kt index 56d45e4..ef77ebb 100644 --- a/hello-vector/src/test/java/com/haroldadmin/hellovector/HelloViewModelTest.kt +++ b/hello-vector/src/test/java/com/haroldadmin/hellovector/HelloViewModelTest.kt @@ -1,47 +1,37 @@ package com.haroldadmin.hellovector import androidx.lifecycle.SavedStateHandle +import com.haroldadmin.vector.test.VectorTestRule import com.haroldadmin.vector.withState -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test -import java.util.concurrent.Executors -import kotlin.coroutines.CoroutineContext -@ExperimentalCoroutinesApi class HelloViewModelTest { + @get:Rule + val vectorTestRule = VectorTestRule() + private lateinit var viewModel: HelloViewModel - private lateinit var testContext: CoroutineContext @Before fun setup() { - val mainThreadSurrogate = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - testContext = mainThreadSurrogate + Job() - Dispatchers.setMain(mainThreadSurrogate) - viewModel = HelloViewModel(HelloState(), testContext, SavedStateHandle()) + viewModel = HelloViewModel(HelloState(), savedStateHandle = SavedStateHandle()) } @Test - fun `should fetch message when initialized`() = runBlocking(testContext) { + fun `should fetch message when initialized`() = runBlocking { val expectedMessage = "Hello, World!" - viewModel.getMessage(delayDuration = 0).join() + + viewModel.getMessage(delayDuration = 1000) + + vectorTestRule.awaitCompletion(viewModel) + withState(viewModel) { state -> assert(state.message == expectedMessage) { "Expected $expectedMessage, got ${state.message}" } } } - - @After - fun cleanup() { - Dispatchers.resetMain() - } } diff --git a/settings.gradle b/settings.gradle index 636573f..b4b4d9f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,4 +2,5 @@ include ':vector' include ':sampleapp' include ':benchmark' include ':hello-vector' +include ':vector-test' rootProject.name='Vector' diff --git a/vector-test/.gitignore b/vector-test/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/vector-test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/vector-test/build.gradle b/vector-test/build.gradle new file mode 100644 index 0000000..9f1a41a --- /dev/null +++ b/vector-test/build.gradle @@ -0,0 +1,59 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'com.github.dcendents.android-maven' +apply plugin: 'org.jetbrains.dokka-android' + +group = "com.github.haroldadmin" + +android { + compileSdkVersion buildConfig.compileSdk + + defaultConfig { + minSdkVersion buildConfig.minSdk + targetSdkVersion buildConfig.targetSdk + versionCode buildConfig.versionCode + versionName buildConfig.versionName + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles 'consumer-rules.pro' + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility 1.8 + targetCompatibility 1.8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dokka { + outputFormat = "gfm" + outputDirectory = "$rootDir/docs/api/" + externalDocumentationLink { + url = new URL("https://developer.android.com/reference/") + packageListUrl = new URL("https://developer.android.com/reference/androidx/package-list") + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + api project(path: ":vector") + api libs.kotlinStdLib + api libs.coroutinesCore + api libs.coroutinesAndroid + api libs.coroutinesTest + api libs.junit + api libs.kotlinReflect + + testImplementation libs.coroutinesTest + testImplementation libs.mockk + testImplementation libs.robolectric +} diff --git a/vector-test/consumer-rules.pro b/vector-test/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/vector-test/proguard-rules.pro b/vector-test/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/vector-test/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/vector-test/src/main/AndroidManifest.xml b/vector-test/src/main/AndroidManifest.xml new file mode 100644 index 0000000..025bdc3 --- /dev/null +++ b/vector-test/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vector-test/src/main/java/com/haroldadmin/vector/test/VectorTestRule.kt b/vector-test/src/main/java/com/haroldadmin/vector/test/VectorTestRule.kt new file mode 100644 index 0000000..f4b60ee --- /dev/null +++ b/vector-test/src/main/java/com/haroldadmin/vector/test/VectorTestRule.kt @@ -0,0 +1,38 @@ +package com.haroldadmin.vector.test + +import com.haroldadmin.vector.VectorViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import kotlin.reflect.full.callSuspend +import kotlin.reflect.full.memberFunctions + +/** + * A JUnit test rule which handles delegating [Dispatchers.Main] responsibility to a regular Dispatcher, which can + * be configured using the [delegateDispatcher] parameter. It also provides a utility method to wait until + * all coroutines in a [VectorViewModel] have finished processing. + * + * Tests for a [VectorViewModel] are expected to call [awaitCompletion] before making assertions on its state + */ +class VectorTestRule( + private val delegateDispatcher: CoroutineDispatcher = Dispatchers.Unconfined +) : TestWatcher() { + + override fun starting(description: Description?) { + super.starting(description) + Dispatchers.setMain(delegateDispatcher) + } + + override fun finished(description: Description?) { + super.finished(description) + Dispatchers.resetMain() + } + + suspend fun awaitCompletion(viewModel: VectorViewModel<*>) { + val drainMethod = viewModel::class.memberFunctions.first { it.name == "drain" } + drainMethod.callSuspend(viewModel) + } +} \ No newline at end of file diff --git a/vector/src/main/java/com/haroldadmin/vector/Extensions.kt b/vector/src/main/java/com/haroldadmin/vector/Extensions.kt index 4a84b6f..469bcfd 100644 --- a/vector/src/main/java/com/haroldadmin/vector/Extensions.kt +++ b/vector/src/main/java/com/haroldadmin/vector/Extensions.kt @@ -2,6 +2,7 @@ package com.haroldadmin.vector import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.ConflatedBroadcastChannel import kotlinx.coroutines.flow.collect @@ -92,3 +93,13 @@ internal inline fun ConflatedBroadcastChannel.compute(crossinline newValu this.offer(newValue) return true } + +/** + * Joins on all the children jobs of this parent Job. + * + * Useful for waiting until all the children of a coroutine scope have finished execution, without waiting for + * the coroutine scope itself to close. + */ +internal suspend fun Job.joinChildren() { + children.forEach { it.join() } +} diff --git a/vector/src/main/java/com/haroldadmin/vector/VectorViewModel.kt b/vector/src/main/java/com/haroldadmin/vector/VectorViewModel.kt index 21fd70f..20a6395 100644 --- a/vector/src/main/java/com/haroldadmin/vector/VectorViewModel.kt +++ b/vector/src/main/java/com/haroldadmin/vector/VectorViewModel.kt @@ -2,6 +2,7 @@ package com.haroldadmin.vector import androidx.annotation.CallSuper import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.haroldadmin.vector.loggers.Logger import com.haroldadmin.vector.loggers.androidLogger import com.haroldadmin.vector.loggers.logd @@ -66,7 +67,6 @@ abstract class VectorViewModel( * the state store, but not necessarily immediately * * @param action The state reducer to create a new state from the current state - * */ protected fun setState(action: suspend S.() -> S) { stateStore.offerSetAction(action) @@ -81,12 +81,22 @@ abstract class VectorViewModel( * processor does not get blocked if a particular action takes too long to finish. * * @param action The action to be performed with the current state - * */ protected fun withState(action: suspend (S) -> Unit) { stateStore.offerGetAction(action) } + /** + * A testing utility to wait until all the coroutines launched in this ViewModel have finished executing, as well + * as it's [com.haroldadmin.vector.state.StateProcessor] has been drained. + */ + internal suspend fun drain() { + logger.logv { "Draining viewModelScope" } + viewModelScope.coroutineContext[Job]?.joinChildren() + logger.logv { "Draining state processor" } + stateStore.stateProcessor.drain() + } + /** * Clears this ViewModel as well as its [stateStore]. */ diff --git a/vector/src/main/java/com/haroldadmin/vector/state/SelectBasedStateProcessor.kt b/vector/src/main/java/com/haroldadmin/vector/state/SelectBasedStateProcessor.kt index 028a2bd..60163fc 100644 --- a/vector/src/main/java/com/haroldadmin/vector/state/SelectBasedStateProcessor.kt +++ b/vector/src/main/java/com/haroldadmin/vector/state/SelectBasedStateProcessor.kt @@ -5,6 +5,7 @@ import com.haroldadmin.vector.loggers.Logger import com.haroldadmin.vector.loggers.logd import com.haroldadmin.vector.loggers.logv import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope @@ -38,7 +39,7 @@ internal class SelectBasedStateProcessor( /** * [CoroutineScope] for managing coroutines in this state processor */ - val processorScope = CoroutineScope(coroutineContext) + private val processorScope = CoroutineScope(coroutineContext) /** * Queue for state reducers. @@ -62,16 +63,10 @@ internal class SelectBasedStateProcessor( if (shouldStartImmediately) { start() } else { - logger.logv { "Starting in Lazy mode. Call start() to begin processing actions and reducers" } + logger.logv { "Starting in Lazy mode. Call start()/drain() to begin processing actions and reducers" } } } - /** - * Enqueues the given [reducer] to an internal queue - * - * If the state processor has been cleared before this reducer is offered, then it is ignored and not added - * to the queue to be processed - */ override fun offerSetAction(reducer: suspend S.() -> S) { if (processorScope.isActive && !setStateChannel.isClosedForSend) { // TODO Look for a solution to the case where the channel could be closed between the check and this offer @@ -80,12 +75,6 @@ internal class SelectBasedStateProcessor( } } - /** - * Enqueues the given [action] to an internal queue - * - * If the state processor has been cleared before this action is offered, then it is ignored and not added - * to the queue to be processed. - */ override fun offerGetAction(action: suspend (S) -> Unit) { if (processorScope.isActive && !getStateChannel.isClosedForSend) { // TODO Look for a solution to the case where the channel could be closed between the check and this offer @@ -94,11 +83,6 @@ internal class SelectBasedStateProcessor( } } - /** - * Cancels this processor's coroutine scope and stops processing of jobs. - * - * Repeated invocations have no effect. - */ override fun clearProcessor() { if (processorScope.isActive) { logger.logd { "Clearing StateProcessor $this" } @@ -108,26 +92,15 @@ internal class SelectBasedStateProcessor( } } - /** - * Launches a coroutine to start processing jobs sent to it. - * - * Jobs are processed continuously until the [processorScope] is cancelled using [clearProcessor] - */ - internal fun start() = processorScope.launch { - while (isActive) { - selectJob() + override fun start() { + processorScope.launch { + while (isActive) { + selectJob() + } } } - /** - * A testing/benchmarking utility to process all state updates and reducers from both channels, and surface any - * errors to the caller. This method should only be used if all the jobs to be processed have been already - * enqueued to the state processor. - * - * After the processor is drained, it means that all state-reducers have been processed, and that all launched - * coroutines for state-actions have finished execution. - */ - internal suspend fun drain() { + override suspend fun drain() { do { coroutineScope { // Process all jobs currently in the queues diff --git a/vector/src/main/java/com/haroldadmin/vector/state/StateProcessor.kt b/vector/src/main/java/com/haroldadmin/vector/state/StateProcessor.kt index 51e2e78..8da81fc 100644 --- a/vector/src/main/java/com/haroldadmin/vector/state/StateProcessor.kt +++ b/vector/src/main/java/com/haroldadmin/vector/state/StateProcessor.kt @@ -31,10 +31,29 @@ interface StateProcessor { */ fun offerGetAction(action: action) + /** - * Cleanup any resources held by this processor. + * Launches a coroutine to start processing jobs sent to it. Jobs are continuously processed until the + * StateProcessor is cleared + */ + fun start() + + /** + * Cancels this processor's coroutine scope and stops processing of jobs. + * + * This operation should be idempotent in its implementation */ fun clearProcessor() + + /** + * A testing/benchmarking utility to process all pre-enqueued state updates and reducers from both channels, and + * surface any errors to the caller. This method should only be used if all the jobs to be processed have been + * already enqueued to the state processor. + * + * After the processor is drained, it means that all state-reducers have been processed, and that all launched + * coroutines for state-actions have finished execution. + */ + suspend fun drain() } internal typealias reducer = suspend S.() -> S diff --git a/vector/src/main/java/com/haroldadmin/vector/state/StateStore.kt b/vector/src/main/java/com/haroldadmin/vector/state/StateStore.kt index 6b7b6ab..4a2a753 100644 --- a/vector/src/main/java/com/haroldadmin/vector/state/StateStore.kt +++ b/vector/src/main/java/com/haroldadmin/vector/state/StateStore.kt @@ -9,8 +9,8 @@ import com.haroldadmin.vector.VectorState * @param stateProcessor The delegate to handle [StateProcessor] functions */ abstract class StateStore( - protected open val stateHolder: StateHolder, - protected open val stateProcessor: StateProcessor + internal open val stateHolder: StateHolder, + internal open val stateProcessor: StateProcessor ) : StateHolder by stateHolder, StateProcessor by stateProcessor { /**