Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions hello-vector/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ include ':vector'
include ':sampleapp'
include ':benchmark'
include ':hello-vector'
include ':vector-test'
rootProject.name='Vector'
1 change: 1 addition & 0 deletions vector-test/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
59 changes: 59 additions & 0 deletions vector-test/build.gradle
Original file line number Diff line number Diff line change
@@ -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
}
Empty file added vector-test/consumer-rules.pro
Empty file.
21 changes: 21 additions & 0 deletions vector-test/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions vector-test/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<manifest package="com.haroldadmin.vector.test" />
Original file line number Diff line number Diff line change
@@ -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)
}
}
11 changes: 11 additions & 0 deletions vector/src/main/java/com/haroldadmin/vector/Extensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -92,3 +93,13 @@ internal inline fun <T> ConflatedBroadcastChannel<T>.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() }
}
14 changes: 12 additions & 2 deletions vector/src/main/java/com/haroldadmin/vector/VectorViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -66,7 +67,6 @@ abstract class VectorViewModel<S : VectorState>(
* 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)
Expand All @@ -81,12 +81,22 @@ abstract class VectorViewModel<S : VectorState>(
* 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].
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -38,7 +39,7 @@ internal class SelectBasedStateProcessor<S : VectorState>(
/**
* [CoroutineScope] for managing coroutines in this state processor
*/
val processorScope = CoroutineScope(coroutineContext)
private val processorScope = CoroutineScope(coroutineContext)

/**
* Queue for state reducers.
Expand All @@ -62,16 +63,10 @@ internal class SelectBasedStateProcessor<S : VectorState>(
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
Expand All @@ -80,12 +75,6 @@ internal class SelectBasedStateProcessor<S : VectorState>(
}
}

/**
* 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
Expand All @@ -94,11 +83,6 @@ internal class SelectBasedStateProcessor<S : VectorState>(
}
}

/**
* 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" }
Expand All @@ -108,26 +92,15 @@ internal class SelectBasedStateProcessor<S : VectorState>(
}
}

/**
* 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
Expand Down
Loading