Support Rx2 (#531)
* Add rx2 module * Support Rx2 Signed-off-by: Matt Ramotar <mramotar@dropbox.com> * Add unit tests Signed-off-by: Matt Ramotar <mramotar@dropbox.com> * Format Signed-off-by: Matt Ramotar <mramotar@dropbox.com> --------- Signed-off-by: Matt Ramotar <mramotar@dropbox.com>
This commit is contained in:
parent
66d18cb026
commit
7d73f08cc0
15 changed files with 772 additions and 0 deletions
|
@ -39,6 +39,7 @@ object Deps {
|
||||||
const val serializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:${Version.kotlinxSerialization}"
|
const val serializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:${Version.kotlinxSerialization}"
|
||||||
const val coroutinesAndroid = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Version.kotlinxCoroutines}"
|
const val coroutinesAndroid = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Version.kotlinxCoroutines}"
|
||||||
const val coroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Version.kotlinxCoroutines}"
|
const val coroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Version.kotlinxCoroutines}"
|
||||||
|
const val coroutinesRx2 = "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:${Version.kotlinxCoroutines}"
|
||||||
const val dateTime = "org.jetbrains.kotlinx:kotlinx-datetime:0.4.0"
|
const val dateTime = "org.jetbrains.kotlinx:kotlinx-datetime:0.4.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,6 +48,10 @@ object Deps {
|
||||||
const val clientCio = "io.ktor:ktor-client-cio:${Version.ktor}"
|
const val clientCio = "io.ktor:ktor-client-cio:${Version.ktor}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object Rx {
|
||||||
|
const val rx2 = "io.reactivex.rxjava2:rxjava:2.2.21"
|
||||||
|
}
|
||||||
|
|
||||||
object SqlDelight {
|
object SqlDelight {
|
||||||
const val gradlePlugin = "com.squareup.sqldelight:gradle-plugin:${Version.sqlDelight}"
|
const val gradlePlugin = "com.squareup.sqldelight:gradle-plugin:${Version.sqlDelight}"
|
||||||
const val driverAndroid = "com.squareup.sqldelight:android-driver:${Version.sqlDelight}"
|
const val driverAndroid = "com.squareup.sqldelight:android-driver:${Version.sqlDelight}"
|
||||||
|
@ -61,6 +66,7 @@ object Deps {
|
||||||
const val core = "androidx.test:core:${Version.testCore}"
|
const val core = "androidx.test:core:${Version.testCore}"
|
||||||
const val coroutinesTest = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Version.kotlinxCoroutines}"
|
const val coroutinesTest = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Version.kotlinxCoroutines}"
|
||||||
const val junit = "junit:junit:${Version.junit}"
|
const val junit = "junit:junit:${Version.junit}"
|
||||||
|
const val truth = "com.google.truth:truth:${Version.truth}"
|
||||||
}
|
}
|
||||||
|
|
||||||
object Touchlab {
|
object Touchlab {
|
||||||
|
|
|
@ -33,4 +33,5 @@ object Version {
|
||||||
const val sqlDelight = "1.5.4"
|
const val sqlDelight = "1.5.4"
|
||||||
const val sqlDelightGradlePlugin = sqlDelight
|
const val sqlDelightGradlePlugin = sqlDelight
|
||||||
const val store = "5.0.0-alpha05"
|
const val store = "5.0.0-alpha05"
|
||||||
|
const val truth = "1.1.3"
|
||||||
}
|
}
|
64
rx2/build.gradle.kts
Normal file
64
rx2/build.gradle.kts
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
@file:Suppress("UnstableApiUsage")
|
||||||
|
|
||||||
|
import com.vanniktech.maven.publish.SonatypeHost.S01
|
||||||
|
import org.jetbrains.dokka.gradle.DokkaTask
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
kotlin("android")
|
||||||
|
id("com.android.library")
|
||||||
|
id("com.vanniktech.maven.publish")
|
||||||
|
id("org.jetbrains.dokka")
|
||||||
|
id("org.jetbrains.kotlinx.kover")
|
||||||
|
`maven-publish`
|
||||||
|
kotlin("native.cocoapods")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(Deps.Kotlinx.coroutinesRx2)
|
||||||
|
implementation(Deps.Kotlinx.coroutinesCore)
|
||||||
|
implementation(Deps.Kotlinx.coroutinesAndroid)
|
||||||
|
implementation(Deps.Rx.rx2)
|
||||||
|
implementation(project(":store"))
|
||||||
|
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
with(Deps.Test) {
|
||||||
|
testImplementation(junit)
|
||||||
|
testImplementation(core)
|
||||||
|
testImplementation(coroutinesTest)
|
||||||
|
testImplementation(truth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = 33
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 33
|
||||||
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
disable += "ComposableModifierFactory"
|
||||||
|
disable += "ModifierFactoryExtensionFunction"
|
||||||
|
disable += "ModifierFactoryReturnType"
|
||||||
|
disable += "ModifierFactoryUnreferencedReceiver"
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<DokkaTask>().configureEach {
|
||||||
|
dokkaSourceSets.configureEach {
|
||||||
|
reportUndocumented.set(false)
|
||||||
|
skipDeprecated.set(true)
|
||||||
|
jdkVersion.set(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mavenPublishing {
|
||||||
|
publishToMavenCentral(S01)
|
||||||
|
signAllPublications()
|
||||||
|
}
|
3
rx2/gradle.properties
Normal file
3
rx2/gradle.properties
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
POM_NAME=org.mobilenativefoundation.store
|
||||||
|
POM_ARTIFACT_ID=rx2
|
||||||
|
POM_PACKAGING=jar
|
2
rx2/src/main/AndroidManifest.xml
Normal file
2
rx2/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest package="org.mobilenativefoundation.store.rx2" />
|
|
@ -0,0 +1,70 @@
|
||||||
|
package org.mobilenativefoundation.store.rx2
|
||||||
|
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.Single
|
||||||
|
import kotlinx.coroutines.reactive.asFlow
|
||||||
|
import org.mobilenativefoundation.store.store5.Fetcher
|
||||||
|
import org.mobilenativefoundation.store.store5.FetcherResult
|
||||||
|
import org.mobilenativefoundation.store.store5.Store
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new [Fetcher] from a [flowableFactory].
|
||||||
|
*
|
||||||
|
* [Store] does not catch exception thrown in [flowableFactory] or in the returned [Flowable]. These
|
||||||
|
* exception will be propagated to the caller.
|
||||||
|
*
|
||||||
|
* Use when creating a [Store] that fetches objects in a multiple responses per request
|
||||||
|
* network protocol (e.g Web Sockets).
|
||||||
|
*
|
||||||
|
* @param flowableFactory a factory for a [Flowable] source of network records.
|
||||||
|
*/
|
||||||
|
fun <Key : Any, Output : Any> Fetcher.Companion.ofResultFlowable(
|
||||||
|
flowableFactory: (key: Key) -> Flowable<FetcherResult<Output>>
|
||||||
|
): Fetcher<Key, Output> = ofResultFlow { key: Key -> flowableFactory(key).asFlow() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Creates" a [Fetcher] from a [singleFactory].
|
||||||
|
*
|
||||||
|
* [Store] does not catch exception thrown in [singleFactory] or in the returned [Single]. These
|
||||||
|
* exception will be propagated to the caller.
|
||||||
|
*
|
||||||
|
* Use when creating a [Store] that fetches objects in a single response per request network
|
||||||
|
* protocol (e.g Http).
|
||||||
|
*
|
||||||
|
* @param singleFactory a factory for a [Single] source of network records.
|
||||||
|
*/
|
||||||
|
fun <Key : Any, Output : Any> Fetcher.Companion.ofResultSingle(
|
||||||
|
singleFactory: (key: Key) -> Single<FetcherResult<Output>>
|
||||||
|
): Fetcher<Key, Output> = ofResultFlowable { key: Key -> singleFactory(key).toFlowable() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Creates" a [Fetcher] from a [flowableFactory] and translate the results to a [FetcherResult].
|
||||||
|
*
|
||||||
|
* Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the stream then
|
||||||
|
* it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [flowableFactory] itself are
|
||||||
|
* not caught and will be returned to the caller.
|
||||||
|
*
|
||||||
|
* Use when creating a [Store] that fetches objects in a multiple responses per request
|
||||||
|
* network protocol (e.g Web Sockets).
|
||||||
|
*
|
||||||
|
* @param flowFactory a factory for a [Flowable] source of network records.
|
||||||
|
*/
|
||||||
|
fun <Key : Any, Output : Any> Fetcher.Companion.ofFlowable(
|
||||||
|
flowableFactory: (key: Key) -> Flowable<Output>
|
||||||
|
): Fetcher<Key, Output> = ofFlow { key: Key -> flowableFactory(key).asFlow() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new [Fetcher] from a [singleFactory] and translate the results to a [FetcherResult].
|
||||||
|
*
|
||||||
|
* The emitted value will be wrapped in [FetcherResult.Data]. if an exception is returned then
|
||||||
|
* it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [singleFactory] itself are
|
||||||
|
* not caught and will be returned to the caller.
|
||||||
|
*
|
||||||
|
* Use when creating a [Store] that fetches objects in a single response per request network
|
||||||
|
* protocol (e.g Http).
|
||||||
|
*
|
||||||
|
* @param singleFactory a factory for a [Single] source of network records.
|
||||||
|
*/
|
||||||
|
fun <Key : Any, Output : Any> Fetcher.Companion.ofSingle(
|
||||||
|
singleFactory: (key: Key) -> Single<Output>
|
||||||
|
): Fetcher<Key, Output> = ofFlowable { key: Key -> singleFactory(key).toFlowable() }
|
|
@ -0,0 +1,64 @@
|
||||||
|
package org.mobilenativefoundation.store.rx2
|
||||||
|
|
||||||
|
|
||||||
|
import io.reactivex.Completable
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.Maybe
|
||||||
|
import kotlinx.coroutines.reactive.asFlow
|
||||||
|
import kotlinx.coroutines.rx2.await
|
||||||
|
import kotlinx.coroutines.rx2.awaitSingleOrNull
|
||||||
|
import org.mobilenativefoundation.store.store5.SourceOfTruth
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a [Maybe] source of truth that is accessible via [reader], [writer], [delete] and
|
||||||
|
* [deleteAll].
|
||||||
|
*
|
||||||
|
* @param reader function for reading records from the source of truth
|
||||||
|
* @param writer function for writing updates to the backing source of truth
|
||||||
|
* @param delete function for deleting records in the source of truth for the given key
|
||||||
|
* @param deleteAll function for deleting all records in the source of truth
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
fun <Key : Any, Local : Any> SourceOfTruth.Companion.ofMaybe(
|
||||||
|
reader: (Key) -> Maybe<Local>,
|
||||||
|
writer: (Key, Local) -> Completable,
|
||||||
|
delete: ((Key) -> Completable)? = null,
|
||||||
|
deleteAll: (() -> Completable)? = null
|
||||||
|
): SourceOfTruth<Key, Local> {
|
||||||
|
val deleteFun: (suspend (Key) -> Unit)? =
|
||||||
|
if (delete != null) { key -> delete(key).await() } else null
|
||||||
|
val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } }
|
||||||
|
return of(
|
||||||
|
nonFlowReader = { key -> reader.invoke(key).awaitSingleOrNull() },
|
||||||
|
writer = { key, output -> writer.invoke(key, output).await() },
|
||||||
|
delete = deleteFun,
|
||||||
|
deleteAll = deleteAllFun
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a ([Flowable]) source of truth that is accessed via [reader], [writer], [delete] and
|
||||||
|
* [deleteAll].
|
||||||
|
*
|
||||||
|
* @param reader function for reading records from the source of truth
|
||||||
|
* @param writer function for writing updates to the backing source of truth
|
||||||
|
* @param delete function for deleting records in the source of truth for the given key
|
||||||
|
* @param deleteAll function for deleting all records in the source of truth
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
fun <Key : Any, Local : Any> SourceOfTruth.Companion.ofFlowable(
|
||||||
|
reader: (Key) -> Flowable<Local>,
|
||||||
|
writer: (Key, Local) -> Completable,
|
||||||
|
delete: ((Key) -> Completable)? = null,
|
||||||
|
deleteAll: (() -> Completable)? = null
|
||||||
|
): SourceOfTruth<Key, Local> {
|
||||||
|
val deleteFun: (suspend (Key) -> Unit)? =
|
||||||
|
if (delete != null) { key -> delete(key).await() } else null
|
||||||
|
val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } }
|
||||||
|
return of(
|
||||||
|
reader = { key -> reader.invoke(key).asFlow() },
|
||||||
|
writer = { key, output -> writer.invoke(key, output).await() },
|
||||||
|
delete = deleteFun,
|
||||||
|
deleteAll = deleteAllFun
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
package org.mobilenativefoundation.store.rx2
|
||||||
|
|
||||||
|
|
||||||
|
import io.reactivex.Completable
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import kotlinx.coroutines.rx2.asFlowable
|
||||||
|
import kotlinx.coroutines.rx2.rxCompletable
|
||||||
|
import kotlinx.coroutines.rx2.rxSingle
|
||||||
|
import org.mobilenativefoundation.store.store5.ExperimentalStoreApi
|
||||||
|
import org.mobilenativefoundation.store.store5.Store
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreBuilder
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreReadRequest
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreReadResponse
|
||||||
|
import org.mobilenativefoundation.store.store5.impl.extensions.fresh
|
||||||
|
import org.mobilenativefoundation.store.store5.impl.extensions.get
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a [Flowable] for the given key
|
||||||
|
* @param request - see [StoreReadRequest] for configurations
|
||||||
|
*/
|
||||||
|
fun <Key : Any, Output : Any> Store<Key, Output>.observe(request: StoreReadRequest<Key>): Flowable<StoreReadResponse<Output>> =
|
||||||
|
stream(request).asFlowable()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge a particular entry from memory and disk cache.
|
||||||
|
* Persistent storage will only be cleared if a delete function was passed to
|
||||||
|
* [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store].
|
||||||
|
*/
|
||||||
|
fun <Key : Any, Output : Any> Store<Key, Output>.observeClear(key: Key): Completable =
|
||||||
|
rxCompletable { clear(key) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge all entries from memory and disk cache.
|
||||||
|
* Persistent storage will only be cleared if a deleteAll function was passed to
|
||||||
|
* [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store].
|
||||||
|
*/
|
||||||
|
@ExperimentalStoreApi
|
||||||
|
fun <Key : Any, Output : Any> Store<Key, Output>.observeClearAll(): Completable =
|
||||||
|
rxCompletable { clear() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper factory that will return data as a [Single] for [key] if it is cached otherwise will return fresh/network data (updating your caches)
|
||||||
|
*/
|
||||||
|
fun <Key : Any, Output : Any> Store<Key, Output>.getSingle(key: Key) = rxSingle { this@getSingle.get(key) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper factory that will return fresh data as a [Single] for [key] while updating your caches
|
||||||
|
*/
|
||||||
|
fun <Key : Any, Output : Any> Store<Key, Output>.freshSingle(key: Key) = rxSingle { this@freshSingle.fresh(key) }
|
|
@ -0,0 +1,26 @@
|
||||||
|
package org.mobilenativefoundation.store.rx2
|
||||||
|
|
||||||
|
import io.reactivex.Scheduler
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.rx2.asCoroutineDispatcher
|
||||||
|
import org.mobilenativefoundation.store.store5.Store
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreBuilder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by default
|
||||||
|
* [Store] will open a global scope for management of shared responses, if instead you'd like to control
|
||||||
|
* the scheduler that sharing/multicasting happens in you can pass a @param [scheduler]
|
||||||
|
*
|
||||||
|
* Note this does not control what scheduler a response is emitted on but rather what thread/scheduler
|
||||||
|
* to use when managing in flight responses. This is usually used for things like testing where you
|
||||||
|
* may want to confine to a scheduler backed by a single thread executor
|
||||||
|
*
|
||||||
|
* @param scheduler - scheduler to use for sharing
|
||||||
|
* if a scheduler is not set Store will use [GlobalScope]
|
||||||
|
*/
|
||||||
|
fun <Key : Any, Network : Any, Output : Any, Local : Any> StoreBuilder<Key, Network, Output, Local>.withScheduler(
|
||||||
|
scheduler: Scheduler
|
||||||
|
): StoreBuilder<Key, Network, Output, Local> {
|
||||||
|
return scope(CoroutineScope(scheduler.asCoroutineDispatcher()))
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
package org.mobilenativefoundation.store.rx2.test
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Google LLC
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import com.google.common.truth.FailureMetadata
|
||||||
|
import com.google.common.truth.Subject
|
||||||
|
import com.google.common.truth.Truth
|
||||||
|
import com.google.common.truth.Truth.assertWithMessage
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.test.TestCoroutineScope
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
internal fun <T> TestCoroutineScope.assertThat(flow: Flow<T>): FlowSubject<T> {
|
||||||
|
return Truth.assertAbout(FlowSubject.Factory<T>(this)).that(flow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
internal class FlowSubject<T> constructor(
|
||||||
|
failureMetadata: FailureMetadata,
|
||||||
|
private val testCoroutineScope: TestCoroutineScope,
|
||||||
|
private val actual: Flow<T>
|
||||||
|
) : Subject(failureMetadata, actual) {
|
||||||
|
/**
|
||||||
|
* Takes all items in the flow that are available by collecting on it as long as there are
|
||||||
|
* active jobs in the given [TestCoroutineScope].
|
||||||
|
*
|
||||||
|
* It ensures all expected items are dispatched as well as no additional unexpected items are
|
||||||
|
* dispatched.
|
||||||
|
*/
|
||||||
|
suspend fun emitsExactly(vararg expected: T) {
|
||||||
|
val collectedSoFar = mutableListOf<T>()
|
||||||
|
val collectionCoroutine = testCoroutineScope.async {
|
||||||
|
actual.collect {
|
||||||
|
collectedSoFar.add(it)
|
||||||
|
if (collectedSoFar.size > expected.size) {
|
||||||
|
assertWithMessage("Too many emissions in the flow (only first additional item is shown)")
|
||||||
|
.that(collectedSoFar)
|
||||||
|
.isEqualTo(expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
testCoroutineScope.advanceUntilIdle()
|
||||||
|
if (!collectionCoroutine.isActive) {
|
||||||
|
collectionCoroutine.getCompletionExceptionOrNull()?.let {
|
||||||
|
throw it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collectionCoroutine.cancelAndJoin()
|
||||||
|
assertWithMessage("Flow didn't exactly emit expected items")
|
||||||
|
.that(collectedSoFar)
|
||||||
|
.isEqualTo(expected.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory<T>(
|
||||||
|
private val testCoroutineScope: TestCoroutineScope
|
||||||
|
) : Subject.Factory<FlowSubject<T>, Flow<T>> {
|
||||||
|
override fun createSubject(metadata: FailureMetadata, actual: Flow<T>): FlowSubject<T> {
|
||||||
|
return FlowSubject(
|
||||||
|
failureMetadata = metadata,
|
||||||
|
actual = actual,
|
||||||
|
testCoroutineScope = testCoroutineScope
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
package org.mobilenativefoundation.store.rx2.test
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.reactivex.Single
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.test.TestCoroutineScope
|
||||||
|
import kotlinx.coroutines.test.runBlockingTest
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.junit.runners.JUnit4
|
||||||
|
import org.mobilenativefoundation.store.rx2.ofResultSingle
|
||||||
|
import org.mobilenativefoundation.store.store5.Fetcher
|
||||||
|
import org.mobilenativefoundation.store.store5.FetcherResult
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreBuilder
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreReadRequest
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreReadResponse
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin
|
||||||
|
|
||||||
|
@RunWith(JUnit4::class)
|
||||||
|
@FlowPreview
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
class HotRxSingleStoreTest {
|
||||||
|
private val testScope = TestCoroutineScope()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a hot fetcher WHEN two cached and one fresh call THEN fetcher is only called twice`() =
|
||||||
|
testScope.runBlockingTest {
|
||||||
|
val fetcher: FakeRxFetcher<Int, FetcherResult<String>> = FakeRxFetcher(
|
||||||
|
3 to FetcherResult.Data("three-1"),
|
||||||
|
3 to FetcherResult.Data("three-2")
|
||||||
|
)
|
||||||
|
val pipeline = StoreBuilder.from<Int, String, String>(Fetcher.ofResultSingle<Int, String> { fetcher.fetch(it) })
|
||||||
|
.scope(testScope)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
assertThat(pipeline.stream(StoreReadRequest.cached(3, refresh = false)))
|
||||||
|
.emitsExactly(
|
||||||
|
StoreReadResponse.Loading(
|
||||||
|
origin = StoreReadResponseOrigin.Fetcher
|
||||||
|
),
|
||||||
|
StoreReadResponse.Data(
|
||||||
|
value = "three-1",
|
||||||
|
origin = StoreReadResponseOrigin.Fetcher
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertThat(
|
||||||
|
pipeline.stream(StoreReadRequest.cached(3, refresh = false))
|
||||||
|
).emitsExactly(
|
||||||
|
StoreReadResponse.Data(
|
||||||
|
value = "three-1",
|
||||||
|
origin = StoreReadResponseOrigin.Cache
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(pipeline.stream(StoreReadRequest.fresh(3)))
|
||||||
|
.emitsExactly(
|
||||||
|
StoreReadResponse.Loading(
|
||||||
|
origin = StoreReadResponseOrigin.Fetcher
|
||||||
|
),
|
||||||
|
StoreReadResponse.Data(
|
||||||
|
value = "three-2",
|
||||||
|
origin = StoreReadResponseOrigin.Fetcher
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeRxFetcher<Key, Output>(
|
||||||
|
vararg val responses: Pair<Key, Output>
|
||||||
|
) {
|
||||||
|
private var index = 0
|
||||||
|
|
||||||
|
@Suppress("RedundantSuspendModifier") // needed for function reference
|
||||||
|
fun fetch(key: Key): Single<Output> {
|
||||||
|
// will throw if fetcher called more than twice
|
||||||
|
if (index >= responses.size) {
|
||||||
|
throw AssertionError("unexpected fetch request")
|
||||||
|
}
|
||||||
|
val pair = responses[index++]
|
||||||
|
assertThat(pair.first).isEqualTo(key)
|
||||||
|
return Single.just(pair.second)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,110 @@
|
||||||
|
package org.mobilenativefoundation.store.rx2.test
|
||||||
|
|
||||||
|
import io.reactivex.BackpressureStrategy
|
||||||
|
import io.reactivex.Completable
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.schedulers.TestScheduler
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.junit.runners.JUnit4
|
||||||
|
import org.mobilenativefoundation.store.rx2.observe
|
||||||
|
import org.mobilenativefoundation.store.rx2.ofFlowable
|
||||||
|
import org.mobilenativefoundation.store.rx2.ofResultFlowable
|
||||||
|
import org.mobilenativefoundation.store.rx2.withScheduler
|
||||||
|
import org.mobilenativefoundation.store.store5.Fetcher
|
||||||
|
import org.mobilenativefoundation.store.store5.FetcherResult
|
||||||
|
import org.mobilenativefoundation.store.store5.SourceOfTruth
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreBuilder
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreReadRequest
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreReadResponse
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
@RunWith(JUnit4::class)
|
||||||
|
@FlowPreview
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
class RxFlowableStoreTest {
|
||||||
|
private val testScheduler = TestScheduler()
|
||||||
|
private val atomicInteger = AtomicInteger(0)
|
||||||
|
private val fakeDisk = mutableMapOf<Int, String>()
|
||||||
|
private val store = StoreBuilder.from<Int, String, String, String>(
|
||||||
|
fetcher = Fetcher.ofResultFlowable<Int, String> {
|
||||||
|
Flowable.create(
|
||||||
|
{ emitter ->
|
||||||
|
emitter.onNext(
|
||||||
|
FetcherResult.Data("$it ${atomicInteger.incrementAndGet()} occurrence")
|
||||||
|
)
|
||||||
|
emitter.onNext(
|
||||||
|
FetcherResult.Data("$it ${atomicInteger.incrementAndGet()} occurrence")
|
||||||
|
)
|
||||||
|
emitter.onComplete()
|
||||||
|
},
|
||||||
|
BackpressureStrategy.BUFFER
|
||||||
|
)
|
||||||
|
},
|
||||||
|
sourceOfTruth = SourceOfTruth.ofFlowable<Int, String>(
|
||||||
|
reader = {
|
||||||
|
if (fakeDisk[it] != null)
|
||||||
|
Flowable.fromCallable { fakeDisk[it]!! }
|
||||||
|
else
|
||||||
|
Flowable.empty<String>()
|
||||||
|
},
|
||||||
|
writer = { key, value ->
|
||||||
|
Completable.fromAction { fakeDisk[key] = value }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.withScheduler(testScheduler)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun simpleTest() {
|
||||||
|
val testSubscriber1 = store.observe(StoreReadRequest.fresh(3))
|
||||||
|
.subscribeOn(testScheduler)
|
||||||
|
.test()
|
||||||
|
testScheduler.triggerActions()
|
||||||
|
testSubscriber1
|
||||||
|
.awaitCount(3)
|
||||||
|
.assertValues(
|
||||||
|
StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher),
|
||||||
|
StoreReadResponse.Data("3 1 occurrence", StoreReadResponseOrigin.Fetcher),
|
||||||
|
StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.Fetcher)
|
||||||
|
)
|
||||||
|
|
||||||
|
val testSubscriber2 = store.observe(StoreReadRequest.cached(3, false))
|
||||||
|
.subscribeOn(testScheduler)
|
||||||
|
.test()
|
||||||
|
testScheduler.triggerActions()
|
||||||
|
testSubscriber2
|
||||||
|
.awaitCount(2)
|
||||||
|
.assertValues(
|
||||||
|
StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.Cache),
|
||||||
|
StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.SourceOfTruth)
|
||||||
|
)
|
||||||
|
|
||||||
|
val testSubscriber3 = store.observe(StoreReadRequest.fresh(3))
|
||||||
|
.subscribeOn(testScheduler)
|
||||||
|
.test()
|
||||||
|
testScheduler.triggerActions()
|
||||||
|
testSubscriber3
|
||||||
|
.awaitCount(3)
|
||||||
|
.assertValues(
|
||||||
|
StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher),
|
||||||
|
StoreReadResponse.Data("3 3 occurrence", StoreReadResponseOrigin.Fetcher),
|
||||||
|
StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.Fetcher)
|
||||||
|
)
|
||||||
|
|
||||||
|
val testSubscriber4 = store.observe(StoreReadRequest.cached(3, false))
|
||||||
|
.subscribeOn(testScheduler)
|
||||||
|
.test()
|
||||||
|
testScheduler.triggerActions()
|
||||||
|
testSubscriber4
|
||||||
|
.awaitCount(2)
|
||||||
|
.assertValues(
|
||||||
|
StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.Cache),
|
||||||
|
StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.SourceOfTruth)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,78 @@
|
||||||
|
package org.mobilenativefoundation.store.rx2.test
|
||||||
|
|
||||||
|
import io.reactivex.Completable
|
||||||
|
import io.reactivex.Maybe
|
||||||
|
import io.reactivex.Single
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.junit.runners.JUnit4
|
||||||
|
import org.mobilenativefoundation.store.rx2.freshSingle
|
||||||
|
import org.mobilenativefoundation.store.rx2.getSingle
|
||||||
|
import org.mobilenativefoundation.store.rx2.ofMaybe
|
||||||
|
import org.mobilenativefoundation.store.rx2.ofResultSingle
|
||||||
|
import org.mobilenativefoundation.store.rx2.withScheduler
|
||||||
|
import org.mobilenativefoundation.store.store5.ExperimentalStoreApi
|
||||||
|
import org.mobilenativefoundation.store.store5.Fetcher
|
||||||
|
import org.mobilenativefoundation.store.store5.FetcherResult
|
||||||
|
import org.mobilenativefoundation.store.store5.SourceOfTruth
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreBuilder
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
@ExperimentalStoreApi
|
||||||
|
@RunWith(JUnit4::class)
|
||||||
|
@FlowPreview
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
class RxSingleStoreExtensionsTest {
|
||||||
|
private val atomicInteger = AtomicInteger(0)
|
||||||
|
private var fakeDisk = mutableMapOf<Int, String>()
|
||||||
|
private val store =
|
||||||
|
StoreBuilder.from<Int, String, String, String>(
|
||||||
|
fetcher = Fetcher.ofResultSingle {
|
||||||
|
Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") }
|
||||||
|
},
|
||||||
|
sourceOfTruth = SourceOfTruth.ofMaybe(
|
||||||
|
reader = { Maybe.fromCallable<String> { fakeDisk[it] } },
|
||||||
|
writer = { key, value ->
|
||||||
|
Completable.fromAction { fakeDisk[key] = value }
|
||||||
|
},
|
||||||
|
delete = { key ->
|
||||||
|
Completable.fromAction { fakeDisk.remove(key) }
|
||||||
|
},
|
||||||
|
deleteAll = {
|
||||||
|
Completable.fromAction { fakeDisk.clear() }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.withScheduler(Schedulers.trampoline())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `store rx extension tests`() {
|
||||||
|
// Return from cache - after initial fetch
|
||||||
|
store.getSingle(3)
|
||||||
|
.test()
|
||||||
|
.await()
|
||||||
|
.assertValue("3 1")
|
||||||
|
|
||||||
|
// Return from cache
|
||||||
|
store.getSingle(3)
|
||||||
|
.test()
|
||||||
|
.await()
|
||||||
|
.assertValue("3 1")
|
||||||
|
|
||||||
|
// Return from fresh - forcing a new fetch
|
||||||
|
store.freshSingle(3)
|
||||||
|
.test()
|
||||||
|
.await()
|
||||||
|
.assertValue("3 2")
|
||||||
|
|
||||||
|
// Return from cache - different to initial
|
||||||
|
store.getSingle(3)
|
||||||
|
.test()
|
||||||
|
.await()
|
||||||
|
.assertValue("3 2")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,129 @@
|
||||||
|
package org.mobilenativefoundation.store.rx2.test
|
||||||
|
|
||||||
|
import io.reactivex.Completable
|
||||||
|
import io.reactivex.Maybe
|
||||||
|
import io.reactivex.Single
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.junit.runners.JUnit4
|
||||||
|
import org.mobilenativefoundation.store.rx2.observe
|
||||||
|
import org.mobilenativefoundation.store.rx2.observeClear
|
||||||
|
import org.mobilenativefoundation.store.rx2.observeClearAll
|
||||||
|
import org.mobilenativefoundation.store.rx2.ofMaybe
|
||||||
|
import org.mobilenativefoundation.store.rx2.ofResultSingle
|
||||||
|
import org.mobilenativefoundation.store.rx2.withScheduler
|
||||||
|
import org.mobilenativefoundation.store.store5.ExperimentalStoreApi
|
||||||
|
import org.mobilenativefoundation.store.store5.Fetcher
|
||||||
|
import org.mobilenativefoundation.store.store5.FetcherResult
|
||||||
|
import org.mobilenativefoundation.store.store5.SourceOfTruth
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreBuilder
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreReadRequest
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreReadResponse
|
||||||
|
import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
@ExperimentalStoreApi
|
||||||
|
@RunWith(JUnit4::class)
|
||||||
|
@FlowPreview
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
class RxSingleStoreTest {
|
||||||
|
private val atomicInteger = AtomicInteger(0)
|
||||||
|
private var fakeDisk = mutableMapOf<Int, String>()
|
||||||
|
private val store =
|
||||||
|
StoreBuilder.from<Int, String, String, String>(
|
||||||
|
fetcher = Fetcher.ofResultSingle {
|
||||||
|
Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") }
|
||||||
|
},
|
||||||
|
sourceOfTruth = SourceOfTruth.ofMaybe(
|
||||||
|
reader = { Maybe.fromCallable<String> { fakeDisk[it] } },
|
||||||
|
writer = { key, value ->
|
||||||
|
Completable.fromAction { fakeDisk[key] = value }
|
||||||
|
},
|
||||||
|
delete = { key ->
|
||||||
|
Completable.fromAction { fakeDisk.remove(key) }
|
||||||
|
},
|
||||||
|
deleteAll = {
|
||||||
|
Completable.fromAction { fakeDisk.clear() }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.withScheduler(Schedulers.trampoline())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun simpleTest() {
|
||||||
|
store.observe(StoreReadRequest.cached(3, false))
|
||||||
|
.test()
|
||||||
|
.awaitCount(2)
|
||||||
|
.assertValues(
|
||||||
|
StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher),
|
||||||
|
StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher)
|
||||||
|
)
|
||||||
|
|
||||||
|
store.observe(StoreReadRequest.cached(3, false))
|
||||||
|
.test()
|
||||||
|
.awaitCount(2)
|
||||||
|
.assertValues(
|
||||||
|
StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Cache),
|
||||||
|
StoreReadResponse.Data("3 1", StoreReadResponseOrigin.SourceOfTruth)
|
||||||
|
)
|
||||||
|
|
||||||
|
store.observe(StoreReadRequest.fresh(3))
|
||||||
|
.test()
|
||||||
|
.awaitCount(2)
|
||||||
|
.assertValues(
|
||||||
|
StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher),
|
||||||
|
StoreReadResponse.Data("3 2", StoreReadResponseOrigin.Fetcher)
|
||||||
|
)
|
||||||
|
|
||||||
|
store.observe(StoreReadRequest.cached(3, false))
|
||||||
|
.test()
|
||||||
|
.awaitCount(2)
|
||||||
|
.assertValues(
|
||||||
|
StoreReadResponse.Data("3 2", StoreReadResponseOrigin.Cache),
|
||||||
|
StoreReadResponse.Data("3 2", StoreReadResponseOrigin.SourceOfTruth)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a store with persister values WHEN observeClear is Called THEN next Store get hits network`() {
|
||||||
|
fakeDisk[3] = "seeded occurrence"
|
||||||
|
|
||||||
|
store.observeClear(3).blockingGet()
|
||||||
|
|
||||||
|
store.observe(StoreReadRequest.cached(3, false))
|
||||||
|
.test()
|
||||||
|
.awaitCount(2)
|
||||||
|
.assertValues(
|
||||||
|
StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher),
|
||||||
|
StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a store with persister values WHEN observeClearAll is called THEN next Store get calls both hit network`() {
|
||||||
|
fakeDisk[3] = "seeded occurrence"
|
||||||
|
fakeDisk[4] = "another seeded occurrence"
|
||||||
|
|
||||||
|
store.observeClearAll().blockingGet()
|
||||||
|
|
||||||
|
store.observe(StoreReadRequest.cached(3, false))
|
||||||
|
.test()
|
||||||
|
.awaitCount(2)
|
||||||
|
.assertValues(
|
||||||
|
StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher),
|
||||||
|
StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher)
|
||||||
|
)
|
||||||
|
|
||||||
|
store.observe(StoreReadRequest.cached(4, false))
|
||||||
|
.test()
|
||||||
|
.awaitCount(2)
|
||||||
|
.assertValues(
|
||||||
|
StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher),
|
||||||
|
StoreReadResponse.Data("4 2", StoreReadResponseOrigin.Fetcher)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
include ':store'
|
include ':store'
|
||||||
include ':cache'
|
include ':cache'
|
||||||
include ':multicast'
|
include ':multicast'
|
||||||
|
include ':rx2'
|
||||||
|
|
Loading…
Reference in a new issue