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 {
/**