From e404e2d418d5a372c6cabe524acf3459fbc7c465 Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Sat, 2 Nov 2019 13:14:18 +0100 Subject: [PATCH 01/19] Experiment to simplify DSL and remove `ignoringEvents` --- orbit/src/main/java/com/babylon/orbit/Dsl.kt | 87 +++++-------------- .../test/java/com/babylon/orbit/OrbitSpek.kt | 10 +-- 2 files changed, 26 insertions(+), 71 deletions(-) diff --git a/orbit/src/main/java/com/babylon/orbit/Dsl.kt b/orbit/src/main/java/com/babylon/orbit/Dsl.kt index 5118bb18..6300935e 100644 --- a/orbit/src/main/java/com/babylon/orbit/Dsl.kt +++ b/orbit/src/main/java/com/babylon/orbit/Dsl.kt @@ -4,6 +4,7 @@ import hu.akarnokd.rxjava2.subjects.UnicastWorkSubject import io.reactivex.Observable import io.reactivex.rxkotlin.cast import io.reactivex.schedulers.Schedulers +import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject /* @@ -37,17 +38,17 @@ open class OrbitsBuilder(private val initialStat fun OrbitsBuilder.perform(description: String) = ActionFilter(description) inline fun ActionFilter.on() = - this@OrbitsBuilder.FirstTransformer { it.ofActionType() } + this@OrbitsBuilder.Transformer>(description, false) { it.ofActionType() } @Suppress("unused") // Used for the nice extension function highlight - fun ActionFilter.on(vararg classes: Class<*>) = this@OrbitsBuilder.FirstTransformer { actions -> + fun ActionFilter.on(vararg classes: Class<*>) = this@OrbitsBuilder.Transformer(description, false) { actions -> Observable.merge( classes.map { clazz -> actions.filter { clazz.isInstance(it.action) } } ) } @OrbitDsl - inner class ActionFilter(private val description: String) { + inner class ActionFilter(val description: String) { inline fun Observable>.ofActionType(): Observable> = filter { it.action is ACTION } @@ -55,64 +56,21 @@ open class OrbitsBuilder(private val initialStat .doOnNext { } // TODO logging the flow description } - @OrbitDsl - inner class FirstTransformer( - private val upstreamTransformer: (Observable>) -> Observable> - ) { - - fun transform(transformer: Observable>.() -> Observable) = - this@OrbitsBuilder.Transformer { rawActions -> - transformer(upstreamTransformer(rawActions.observeOn(Schedulers.io()))) - } - - fun postSideEffect(sideEffect: ActionState.() -> SIDE_EFFECT) = - sideEffectInternal { - this@OrbitsBuilder.sideEffectSubject.onNext( - it.sideEffect() - ) - } - - fun sideEffect(sideEffect: ActionState.() -> Unit) = - sideEffectInternal { - it.sideEffect() - } - - private fun sideEffectInternal(sideEffect: (ActionState) -> Unit) = - this@OrbitsBuilder.FirstTransformer { rawActions -> - upstreamTransformer(rawActions) - .doOnNext { - sideEffect(it) - } - } - - fun withReducer(reducer: ReducerReceiver.() -> STATE) { - this@OrbitsBuilder.orbits += { rawActions, _ -> - upstreamTransformer(rawActions) - .map { - { state: STATE -> - ReducerReceiver(state, it.action).reducer() - } - } - } - } - - fun ignoringEvents() { - this@OrbitsBuilder.orbits += { upstream, _ -> - upstreamTransformer(upstream) - .map { - { state: STATE -> state } - } - } - } - } + private val inProgress = mutableMapOf>() @OrbitDsl - inner class Transformer(private val upstreamTransformer: (Observable>) -> Observable) { + inner class Transformer( + private val description: String, + private val ioScheduled: Boolean, + private val upstreamTransformer: (Observable>) -> Observable + ) { fun transform(transformer: Observable.() -> Observable) = - this@OrbitsBuilder.Transformer { rawActions -> - transformer(upstreamTransformer(rawActions)) + this@OrbitsBuilder.Transformer(description, true) { rawActions -> + val actions = if(ioScheduled) rawActions else rawActions.observeOn(Schedulers.io()) + transformer(upstreamTransformer(actions)) } + .also { this@OrbitsBuilder.inProgress[description] = it } fun postSideEffect(sideEffect: EventReceiver.() -> SIDE_EFFECT) = sideEffectInternal { @@ -125,14 +83,16 @@ open class OrbitsBuilder(private val initialStat } private fun sideEffectInternal(sideEffect: (EVENT) -> Unit) = - this@OrbitsBuilder.Transformer { rawActions -> + this@OrbitsBuilder.Transformer(description, false) { rawActions -> upstreamTransformer(rawActions) .doOnNext { sideEffect(it) } } + .also { this@OrbitsBuilder.inProgress[description] = it } fun loopBack(mapper: EventReceiver.() -> T) { + this@OrbitsBuilder.inProgress.remove(description) this@OrbitsBuilder.orbits += { upstream, inputRelay -> upstreamTransformer(upstream) .doOnNext { action -> inputRelay.onNext(EventReceiver(action).mapper()) } @@ -142,16 +102,8 @@ open class OrbitsBuilder(private val initialStat } } - fun ignoringEvents() { - this@OrbitsBuilder.orbits += { upstream, _ -> - upstreamTransformer(upstream) - .map { - { state: STATE -> state } - } - } - } - fun withReducer(reducer: ReducerReceiver.() -> STATE) { + this@OrbitsBuilder.inProgress.remove(description) this@OrbitsBuilder.orbits += { rawActions, _ -> upstreamTransformer(rawActions) .map { @@ -164,6 +116,9 @@ open class OrbitsBuilder(private val initialStat } fun build() = object : Middleware { + init { + ArrayList(inProgress.values).forEach { transformer -> transformer.withReducer { currentState } } + } override val initialState: STATE = this@OrbitsBuilder.initialState override val orbits: List> = this@OrbitsBuilder.orbits override val sideEffect: Observable = sideEffectSubject.hide() diff --git a/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt b/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt index 7590cc77..632cc9fe 100644 --- a/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt +++ b/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt @@ -53,7 +53,7 @@ internal class OrbitSpek : Spek({ middleware = createTestMiddleware { perform("something") .on() - .withReducer { State(currentState.id + event) } + .withReducer { State(currentState.id + event.action) } } orbitContainer = BaseOrbitContainer(middleware) } @@ -134,7 +134,6 @@ internal class OrbitSpek : Spek({ .on() .transform { map { it.action * 2 } } .transform { map { it * 2 } } - .ignoringEvents() perform("unlatch") .on() @@ -142,7 +141,6 @@ internal class OrbitSpek : Spek({ latch.countDown() this } - .ignoringEvents() } orbitContainer = BaseOrbitContainer(middleware) } @@ -286,7 +284,7 @@ internal class OrbitSpek : Spek({ middleware = createTestMiddleware(State(1)) { perform("something") .on() - .postSideEffect { "${inputState.id + action}" } + .postSideEffect { "${event.inputState.id + event.action}" } .withReducer { currentState.copy(id = currentState.id + 1) } } orbitContainer = BaseOrbitContainer(middleware) @@ -299,6 +297,7 @@ internal class OrbitSpek : Spek({ orbitContainer.sendAction(5) testObserver.awaitCount(2) + sideEffects.awaitCount(1) } Then("produces a correct series of states") { @@ -334,6 +333,7 @@ internal class OrbitSpek : Spek({ orbitContainer.sendAction(5) testObserver.awaitCount(2) + sideEffects.awaitCount(1) } Then("produces a correct series of states") { @@ -356,7 +356,7 @@ internal class OrbitSpek : Spek({ middleware = createTestMiddleware(State(1)) { perform("something") .on() - .sideEffect { sideEffectList.add("${inputState.id + action}") } + .sideEffect { sideEffectList.add("${event.inputState.id + event.action}") } .withReducer { currentState.copy(id = currentState.id + 1) } } orbitContainer = BaseOrbitContainer(middleware) From c49508a174effcd921c1b61a198782141d97f20d Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Tue, 5 Nov 2019 09:34:23 +0100 Subject: [PATCH 02/19] Changed the way the side effect blocks work --- orbit/src/main/java/com/babylon/orbit/Dsl.kt | 21 ++++++++----------- .../test/java/com/babylon/orbit/OrbitSpek.kt | 4 ++-- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/orbit/src/main/java/com/babylon/orbit/Dsl.kt b/orbit/src/main/java/com/babylon/orbit/Dsl.kt index 6300935e..35d6c6b6 100644 --- a/orbit/src/main/java/com/babylon/orbit/Dsl.kt +++ b/orbit/src/main/java/com/babylon/orbit/Dsl.kt @@ -72,21 +72,11 @@ open class OrbitsBuilder(private val initialStat } .also { this@OrbitsBuilder.inProgress[description] = it } - fun postSideEffect(sideEffect: EventReceiver.() -> SIDE_EFFECT) = - sideEffectInternal { - this@OrbitsBuilder.sideEffectSubject.onNext(EventReceiver(it).sideEffect()) - } - - fun sideEffect(sideEffect: EventReceiver.() -> Unit) = - sideEffectInternal { - EventReceiver(it).sideEffect() - } - - private fun sideEffectInternal(sideEffect: (EVENT) -> Unit) = + fun sideEffect(sideEffect: SideEffectEventReceiver.() -> Unit) = this@OrbitsBuilder.Transformer(description, false) { rawActions -> upstreamTransformer(rawActions) .doOnNext { - sideEffect(it) + SideEffectEventReceiver(this@OrbitsBuilder.sideEffectSubject, it).sideEffect() } } .also { this@OrbitsBuilder.inProgress[description] = it } @@ -133,3 +123,10 @@ class ReducerReceiver( class EventReceiver( val event: EVENT ) + +class SideEffectEventReceiver( + private val sideEffectRelay: Subject, + val event: EVENT +) { + fun post(sideEffect: SIDE_EFFECT) = sideEffectRelay.onNext(sideEffect) +} diff --git a/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt b/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt index 632cc9fe..467c4deb 100644 --- a/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt +++ b/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt @@ -284,7 +284,7 @@ internal class OrbitSpek : Spek({ middleware = createTestMiddleware(State(1)) { perform("something") .on() - .postSideEffect { "${event.inputState.id + event.action}" } + .sideEffect { post("${event.inputState.id + event.action}") } .withReducer { currentState.copy(id = currentState.id + 1) } } orbitContainer = BaseOrbitContainer(middleware) @@ -320,7 +320,7 @@ internal class OrbitSpek : Spek({ perform("something") .on() .transform { map { it.action * 2 } } - .postSideEffect { event.toString() } + .sideEffect { post(event.toString()) } .withReducer { currentState.copy(id = currentState.id + 1) } } orbitContainer = BaseOrbitContainer(middleware) From 933067c0052957fb284e670a7e64cf21e6e0e70d Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Tue, 5 Nov 2019 12:50:19 +0100 Subject: [PATCH 03/19] DSL overhaul --- README.md | 2 +- docs/orbits.md | 149 +++++++++------- orbit-android/orbit-android_build.gradle.kts | 1 - .../babylon/orbit/AndroidOrbitContainer.kt | 3 - .../java/com/babylon/orbit/OrbitViewModel.kt | 4 +- orbit/orbit_build.gradle.kts | 1 - .../java/com/babylon/orbit/ActionState.kt | 48 ----- .../com/babylon/orbit/BaseOrbitContainer.kt | 50 ++++-- orbit/src/main/java/com/babylon/orbit/Dsl.kt | 108 ++++++++---- .../main/java/com/babylon/orbit/Middleware.kt | 9 +- .../java/com/babylon/orbit/OrbitContainer.kt | 1 + .../test/java/com/babylon/orbit/OrbitSpek.kt | 165 +++++++----------- .../presentation/TodoScreenTransformer.kt | 15 +- .../sample/presentation/TodoViewModel.kt | 9 +- .../orbit/sample/TodoScreenReducerSpek.kt | 14 +- 15 files changed, 288 insertions(+), 291 deletions(-) delete mode 100644 orbit/src/main/java/com/babylon/orbit/ActionState.kt diff --git a/README.md b/README.md index 441ee503..a402329f 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ class CalculatorViewModel : OrbitViewModel (State(), { perform("addition") .on() - .postSideEffect { SideEffect.Toast("Adding ${action.number}") } + .sideEffect { post(SideEffect.Toast("Adding ${event.number}")) } .withReducer { state.copy(currentState.total + event.number) } ... diff --git a/docs/orbits.md b/docs/orbits.md index ba14dcd4..5d91bd9e 100644 --- a/docs/orbits.md +++ b/docs/orbits.md @@ -16,7 +16,7 @@ better. A typical orbit is made of three sections: 1. Action filter 1. Transformer(s) (optional) -1. React to events: Ignore, reduce or loopback +1. React to events (optional): reduce or loopback ## Action filter @@ -29,6 +29,8 @@ Every orbit must begin with an action filter. Here we declare a orbit description using the `perform` keyword. The description passed in will appear in debugging logs if debugging is turned on (WIP). +Unique names must be used for each `perform` within a middleware or view model. + Then we declare an action that this orbit will react to using the `on` keyword. We can also declare a list of actions if this orbit reacts to a few different actions. @@ -46,10 +48,66 @@ observable into an observable of a different type of events. Transformers can be chained to be able to break logic down into smaller pieces and enable reuse. -## Reactions +## Side effects + +We cannot run away from the fact that working with Android will +inherently have some side effects. We've made side effects a first class +citizen in Orbit as we believe that it's better to have a full, clear +view of what side effects are possible in a particular view model. + +This functionality is commonly used for things like truly one-off events, +navigation, logging, analytics etc. + +It comes in two flavors: + +1. `sideEffect` lets us perform side effects that are not intended for + consumption outside the Orbit container. +1. `postSideEffect` sends the value returned from the closure to a relay + that can be subscribed when connecting to the view model. Use this for + view-related side effects like Toasts, Navigation, etc. + +``` kotlin +sealed class SideEffect { + data class Toast(val text: String) : SideEffect() + data class Navigate(val screen: Screen) : SideEffect() +} + +OrbitViewModel(State(), { + + perform("side effect straight on the incoming action") + .on() + .sideEffect { + Timber.log(currentState) + Timber.log(event) + } + + perform("side effect after transformation") + .on() + .transform { this.compose(getRandomNumberUseCase) } + .sideEffect { Timber.log(event) } + + perform("post side effect after transformation") + .on() + .transform { this.compose(getRandomNumberUseCase) } + .sideEffect { post(SideEffect.Toast(event.toString())) } + .sideEffect { post(SideEffect.Navigate(Screen.Home)) } + + perform("post side effect straight on the incoming action") + .on() + .sideEffect { post(SideEffect.Toast(currentState.toString())) } + .sideEffect { post(SideEffect.Toast(event.toString())) } + .sideEffect { post(SideEffect.Navigate(Screen.Home)) } +}) +``` + +The `OrbitContainer` hosted in the `OrbitViewModel` provides a relay that +you can subscribe through the `connect` method on `OrbitViewModel` in order +to receive view-related side effects. + +## Terminating a chain Next we have to declare how we will treat the emissions from the transformed -observable. There are three possible reactions: +observable. There are two possible reactions: ### Reducers @@ -71,18 +129,6 @@ perform("addition") .withReducer { state.copy(currentState.total + event.number) } ``` -### Ignored events - -``` kotlin -perform("add random number") - .on() - .transform { this.map{ … } } - .ignoringEvents() -``` - -If we have an orbit that mainly invokes side effects in response to an action -and does not need to produce a new state, we can ignore it. - ### Loopbacks ``` kotlin @@ -100,62 +146,35 @@ Loopbacks allow you to create feedback loops where events coming from one orbit can create new actions that feed into the system. These are useful to represent a cascade of events. -### Side effects - -We cannot run away from the fact that working with Android will -inherently have some side effects. We've made side effects a first class -citizen in Orbit as we believe that it's better to have a full, clear -view of what side effects are possible in a particular view model. - -This functionality is commonly used for things like truly one-off events, -navigation, logging, analytics etc. - -It comes in two flavors: - -1. `sideEffect` lets us perform side effects that are not intended for - consumption outside the Orbit container. -1. `postSideEffect` sends the value returned from the closure to a relay - that can be subscribed when connecting to the view model. Use this for - view-related side effects like Toasts, Navigation, etc. +### Unterminated chains ``` kotlin -sealed class SideEffect { - data class Toast(val text: String) : SideEffect() - data class Navigate(val screen: Screen) : SideEffect() -} +perform("add random number") + .on() + .sideEffect { Timber.d(it.toString()) } +``` -OrbitViewModel(State(), { +Unterminated chains still work as you would expect. They will not get looped +back or produce a new state at the end, but they can be useful for e.g. +analytics. - perform("side effect straight on the incoming action") - .on() - .sideEffect { state, event -> - Timber.log(inputState) - Timber.log(action) - } - .ignoringEvents() +## Accessing the current state - perform("side effect after transformation") - .on() - .transform { this.compose(getRandomNumberUseCase) } - .sideEffect { Timber.log(event) } - .ignoringEvents() +It's fairly common to read the current state in order to perform some +operation in your transformer, or side effect. You can capture the current +state at any point within each DSL block by simply calling `currentState` - perform("add random number") - .on() - .transform { this.compose(getRandomNumberUseCase) } - .postSideEffect { SideEffect.Toast(event.toString()) } - .postSideEffect { SideEffect.Navigate(Screen.Home) } - .ignoringEvents() +For example: - perform("post side effect straight on the incoming action") - .on() - .postSideEffect { SideEffect.Toast(inputState.toString()) } - .postSideEffect { SideEffect.Toast(action.toString()) } - .postSideEffect { SideEffect.Navigate(Screen.Home) } - .ignoringEvents() -}) +``` kotlin +perform("Toast the current state") + .on() + .sideEffect { post(SideEffect.Toast(currentState.toString())) } ``` -The `OrbitContainer` hosted in the `OrbitViewModel` provides a relay that -you can subscribe through the `connect` method on `OrbitViewModel` in order -to receive view-related side effects. +This property always captures the current state, and so calling this +multiple times within the same DSL block could result in receiving different +values each time as the state gets updated externally. + +The only place where we can consider the current state to be non-volatile +is within a reducer. \ No newline at end of file diff --git a/orbit-android/orbit-android_build.gradle.kts b/orbit-android/orbit-android_build.gradle.kts index d3c6216c..9d4423e3 100644 --- a/orbit-android/orbit-android_build.gradle.kts +++ b/orbit-android/orbit-android_build.gradle.kts @@ -60,7 +60,6 @@ dependencies { kapt(ProjectDependencies.androidLifecycleCompiler) implementation(ProjectDependencies.rxJava2) - implementation(ProjectDependencies.rxRelay) implementation(ProjectDependencies.rxKotlin) implementation(ProjectDependencies.rxAndroid) implementation(ProjectDependencies.autodispose) diff --git a/orbit-android/src/main/java/com/babylon/orbit/AndroidOrbitContainer.kt b/orbit-android/src/main/java/com/babylon/orbit/AndroidOrbitContainer.kt index 1dd412bf..ee8075a1 100644 --- a/orbit-android/src/main/java/com/babylon/orbit/AndroidOrbitContainer.kt +++ b/orbit-android/src/main/java/com/babylon/orbit/AndroidOrbitContainer.kt @@ -25,9 +25,6 @@ class AndroidOrbitContainer private constructor( constructor(middleware: Middleware) : this(BaseOrbitContainer(middleware)) - val state: STATE - get() = delegate.state.blockingGet() - override val orbit: Observable = delegate.orbit.observeOn(AndroidSchedulers.mainThread()) override val sideEffect: Observable = diff --git a/orbit-android/src/main/java/com/babylon/orbit/OrbitViewModel.kt b/orbit-android/src/main/java/com/babylon/orbit/OrbitViewModel.kt index 59704d80..08efcef7 100644 --- a/orbit-android/src/main/java/com/babylon/orbit/OrbitViewModel.kt +++ b/orbit-android/src/main/java/com/babylon/orbit/OrbitViewModel.kt @@ -31,8 +31,8 @@ abstract class OrbitViewModel( private val container: AndroidOrbitContainer = AndroidOrbitContainer(middleware) - val state: STATE - get() = container.state + val currentState: STATE + get() = container.currentState /** * Designed to be called in onStart or onResume, depending on your use case. diff --git a/orbit/orbit_build.gradle.kts b/orbit/orbit_build.gradle.kts index 97d53d57..f1d1861a 100644 --- a/orbit/orbit_build.gradle.kts +++ b/orbit/orbit_build.gradle.kts @@ -27,7 +27,6 @@ dependencies { implementation(kotlin("stdlib-jdk8")) implementation(ProjectDependencies.rxJava2) implementation(ProjectDependencies.rxJava2Extensions) - implementation(ProjectDependencies.rxRelay) implementation(ProjectDependencies.rxKotlin) implementation(ProjectDependencies.javaxInject) diff --git a/orbit/src/main/java/com/babylon/orbit/ActionState.kt b/orbit/src/main/java/com/babylon/orbit/ActionState.kt deleted file mode 100644 index bc8a3656..00000000 --- a/orbit/src/main/java/com/babylon/orbit/ActionState.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2019 Babylon Partners Limited - * - * 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 - * - * http://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. - */ - -package com.babylon.orbit - -import io.reactivex.Observable -import io.reactivex.Scheduler -import io.reactivex.schedulers.Schedulers -import io.reactivex.subjects.PublishSubject -import java.util.concurrent.Executors - -data class ActionState( - val inputState: STATE, - val action: ACTION -) - -fun Observable>.buildOrbit( - middleware: Middleware, - inputRelay: PublishSubject -): Observable { - val scheduler = createSingleScheduler() - return this - .observeOn(scheduler) - .publish { actions -> - Observable.merge( - middleware.orbits.map { transformer -> transformer(actions, inputRelay) } - ) - } - .scan(middleware.initialState) { currentState, partialReducer -> partialReducer(currentState) } - .distinctUntilChanged() -} - -private fun createSingleScheduler(): Scheduler { - return Schedulers.from(Executors.newSingleThreadExecutor()) -} diff --git a/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt b/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt index a47d6eef..6e6093d5 100644 --- a/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt +++ b/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt @@ -17,35 +17,35 @@ package com.babylon.orbit import io.reactivex.Observable -import io.reactivex.Single +import io.reactivex.Scheduler import io.reactivex.disposables.CompositeDisposable import io.reactivex.observables.ConnectableObservable import io.reactivex.rxkotlin.plusAssign +import io.reactivex.schedulers.Schedulers import io.reactivex.subjects.PublishSubject +import java.util.concurrent.Executors class BaseOrbitContainer( middleware: Middleware ) : OrbitContainer { - var state: Single - private set - private val inputRelay: PublishSubject = PublishSubject.create() + private val disposables = CompositeDisposable() + + @Volatile + override var currentState: STATE = middleware.initialState + private set override val orbit: ConnectableObservable override val sideEffect: Observable = middleware.sideEffect - private val disposables = CompositeDisposable() - init { - state = Single.just(middleware.initialState) orbit = inputRelay.doOnSubscribe { disposables += it } .startWith(LifecycleAction.Created) - .map { ActionState(state.blockingGet(), it) } // Attaches the current state to the event - .buildOrbit(middleware, inputRelay) + .buildOrbit(middleware) + .doOnNext { currentState = it } .replay(1) + orbit.connect { disposables += it } - state = orbit - .first(middleware.initialState) } override fun sendAction(action: Any) { @@ -55,4 +55,32 @@ class BaseOrbitContainer( override fun disposeOrbit() { disposables.clear() } + + private fun Observable<*>.buildOrbit(middleware: Middleware): Observable { + val scheduler = createSingleScheduler() + return this + .observeOn(scheduler) + .publish { actions -> + Observable.merge( + middleware.orbits.map { transformer -> + transformer( + { currentState }, + actions, + inputRelay + ) + } + ) + } + .observeOn(scheduler) + .scan(middleware.initialState) { currentState, partialReducer -> + partialReducer( + currentState + ) + } + .distinctUntilChanged() + } + + private fun createSingleScheduler(): Scheduler { + return Schedulers.from(Executors.newSingleThreadExecutor()) + } } diff --git a/orbit/src/main/java/com/babylon/orbit/Dsl.kt b/orbit/src/main/java/com/babylon/orbit/Dsl.kt index 35d6c6b6..a714073d 100644 --- a/orbit/src/main/java/com/babylon/orbit/Dsl.kt +++ b/orbit/src/main/java/com/babylon/orbit/Dsl.kt @@ -2,9 +2,8 @@ package com.babylon.orbit import hu.akarnokd.rxjava2.subjects.UnicastWorkSubject import io.reactivex.Observable -import io.reactivex.rxkotlin.cast +import io.reactivex.rxkotlin.ofType import io.reactivex.schedulers.Schedulers -import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject /* @@ -36,56 +35,77 @@ open class OrbitsBuilder(private val initialStat @Suppress("unused") // Used for the nice extension function highlight fun OrbitsBuilder.perform(description: String) = ActionFilter(description) + .also { + require(!descriptions.contains(description)) { + "Names used in perform must be unique! $description already exists!" + } + descriptions.add(description) + } inline fun ActionFilter.on() = - this@OrbitsBuilder.Transformer>(description, false) { it.ofActionType() } + this@OrbitsBuilder.Transformer( + description, + false + ) { _, rawActions -> rawActions.ofType() } @Suppress("unused") // Used for the nice extension function highlight - fun ActionFilter.on(vararg classes: Class<*>) = this@OrbitsBuilder.Transformer(description, false) { actions -> - Observable.merge( - classes.map { clazz -> actions.filter { clazz.isInstance(it.action) } } - ) - } + fun ActionFilter.on(vararg classes: Class<*>) = + this@OrbitsBuilder.Transformer(description, false) { _, rawActions -> + Observable.merge( + classes.map { clazz -> rawActions.filter { clazz.isInstance(it) } } + ) + } @OrbitDsl - inner class ActionFilter(val description: String) { + inner class ActionFilter(val description: String) - inline fun Observable>.ofActionType(): Observable> = - filter { it.action is ACTION } - .cast>() - .doOnNext { } // TODO logging the flow description - } - - private val inProgress = mutableMapOf>() + private val inProgress = mutableMapOf>() + private val descriptions = mutableSetOf() @OrbitDsl inner class Transformer( private val description: String, private val ioScheduled: Boolean, - private val upstreamTransformer: (Observable>) -> Observable + private val upstreamTransformer: (() -> STATE, Observable<*>) -> Observable ) { - - fun transform(transformer: Observable.() -> Observable) = - this@OrbitsBuilder.Transformer(description, true) { rawActions -> - val actions = if(ioScheduled) rawActions else rawActions.observeOn(Schedulers.io()) - transformer(upstreamTransformer(actions)) + fun transform(transformer: TransformerReceiver.() -> Observable) = + this@OrbitsBuilder.Transformer(description, true) { currentStateProvider, rawActions -> + val actions = if (ioScheduled) rawActions else rawActions.observeOn(Schedulers.io()) + TransformerReceiver( + currentStateProvider, + upstreamTransformer(currentStateProvider, actions) + ).transformer() } .also { this@OrbitsBuilder.inProgress[description] = it } - fun sideEffect(sideEffect: SideEffectEventReceiver.() -> Unit) = - this@OrbitsBuilder.Transformer(description, false) { rawActions -> - upstreamTransformer(rawActions) + fun sideEffect(sideEffect: SideEffectEventReceiver.() -> Unit) = + this@OrbitsBuilder.Transformer( + description, + ioScheduled + ) { currentStateProvider, rawActions -> + upstreamTransformer(currentStateProvider, rawActions) .doOnNext { - SideEffectEventReceiver(this@OrbitsBuilder.sideEffectSubject, it).sideEffect() + SideEffectEventReceiver( + currentStateProvider, + this@OrbitsBuilder.sideEffectSubject, + it + ).sideEffect() } } .also { this@OrbitsBuilder.inProgress[description] = it } - fun loopBack(mapper: EventReceiver.() -> T) { + fun loopBack(mapper: EventReceiver.() -> T) { this@OrbitsBuilder.inProgress.remove(description) - this@OrbitsBuilder.orbits += { upstream, inputRelay -> - upstreamTransformer(upstream) - .doOnNext { action -> inputRelay.onNext(EventReceiver(action).mapper()) } + this@OrbitsBuilder.orbits += { currentStateProvider, rawActions, inputRelay -> + upstreamTransformer(currentStateProvider, rawActions) + .doOnNext { action -> + inputRelay.onNext( + EventReceiver( + currentStateProvider, + action + ).mapper() + ) + } .map { { state: STATE -> state } } @@ -94,8 +114,8 @@ open class OrbitsBuilder(private val initialStat fun withReducer(reducer: ReducerReceiver.() -> STATE) { this@OrbitsBuilder.inProgress.remove(description) - this@OrbitsBuilder.orbits += { rawActions, _ -> - upstreamTransformer(rawActions) + this@OrbitsBuilder.orbits += { currentStateProvider, rawActions, _ -> + upstreamTransformer(currentStateProvider, rawActions) .map { { state: STATE -> ReducerReceiver(state, it).reducer() @@ -107,26 +127,44 @@ open class OrbitsBuilder(private val initialStat fun build() = object : Middleware { init { + // Terminates the unterminated chains with a no-op reducer ArrayList(inProgress.values).forEach { transformer -> transformer.withReducer { currentState } } } + override val initialState: STATE = this@OrbitsBuilder.initialState override val orbits: List> = this@OrbitsBuilder.orbits override val sideEffect: Observable = sideEffectSubject.hide() } } +class TransformerReceiver( + private val currentStateProvider: () -> STATE, + val eventObservable: Observable +) { + val currentState: STATE + get() = currentStateProvider() +} + class ReducerReceiver( val currentState: STATE, val event: EVENT ) -class EventReceiver( +class EventReceiver( + private val currentStateProvider: () -> STATE, val event: EVENT -) +) { + val currentState: STATE + get() = currentStateProvider() +} -class SideEffectEventReceiver( +class SideEffectEventReceiver( + private val currentStateProvider: () -> STATE, private val sideEffectRelay: Subject, val event: EVENT ) { + val currentState: STATE + get() = currentStateProvider() + fun post(sideEffect: SIDE_EFFECT) = sideEffectRelay.onNext(sideEffect) } diff --git a/orbit/src/main/java/com/babylon/orbit/Middleware.kt b/orbit/src/main/java/com/babylon/orbit/Middleware.kt index e97344b9..89f7df79 100644 --- a/orbit/src/main/java/com/babylon/orbit/Middleware.kt +++ b/orbit/src/main/java/com/babylon/orbit/Middleware.kt @@ -17,9 +17,16 @@ package com.babylon.orbit import io.reactivex.Observable +import io.reactivex.Scheduler +import io.reactivex.schedulers.Schedulers import io.reactivex.subjects.PublishSubject +import java.util.concurrent.Executors -typealias TransformerFunction = (Observable>, PublishSubject) -> (Observable<(STATE) -> STATE>) +typealias TransformerFunction = ( + () -> STATE, + Observable<*>, + PublishSubject +) -> (Observable<(STATE) -> STATE>) interface Middleware { val initialState: STATE diff --git a/orbit/src/main/java/com/babylon/orbit/OrbitContainer.kt b/orbit/src/main/java/com/babylon/orbit/OrbitContainer.kt index 2d4df662..89a61aa2 100644 --- a/orbit/src/main/java/com/babylon/orbit/OrbitContainer.kt +++ b/orbit/src/main/java/com/babylon/orbit/OrbitContainer.kt @@ -19,6 +19,7 @@ package com.babylon.orbit import io.reactivex.Observable interface OrbitContainer { + val currentState: STATE val orbit: Observable val sideEffect: Observable fun sendAction(action: Any) diff --git a/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt b/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt index 467c4deb..c716232d 100644 --- a/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt +++ b/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt @@ -17,13 +17,37 @@ package com.babylon.orbit import io.reactivex.observers.TestObserver +import io.reactivex.subjects.PublishSubject +import org.assertj.core.api.Assertions import org.assertj.core.api.Assertions.assertThat import org.spekframework.spek2.Spek import org.spekframework.spek2.style.gherkin.Feature import java.util.concurrent.CountDownLatch internal class OrbitSpek : Spek({ - Feature("Orbit DSL") { + + Feature("Orbit DSL syntax") { + createTestMiddleware { + + perform("something") + .on() + .withReducer { currentState.copy(id = currentState.id + event) } + + perform("something else") + .on() + .loopBack { currentState.id + event } + + perform("something entirely else") + .on() + .sideEffect { println("${currentState.id + event}") } + .transform { eventObservable.map { currentState.id + it + 2 } } + .sideEffect { println("$event") } + .sideEffect { post("$event") } + .withReducer { State(currentState.id + event) } + } + } + + Feature("Orbit DSL tests") { Scenario("no flows") { lateinit var middleware: Middleware @@ -53,7 +77,7 @@ internal class OrbitSpek : Spek({ middleware = createTestMiddleware { perform("something") .on() - .withReducer { State(currentState.id + event.action) } + .withReducer { State(currentState.id + event) } } orbitContainer = BaseOrbitContainer(middleware) } @@ -78,7 +102,7 @@ internal class OrbitSpek : Spek({ middleware = createTestMiddleware { perform("something") .on() - .transform { map { it.action * 2 } } + .transform { eventObservable.map { it * 2 } } .withReducer { State(currentState.id + event) } } orbitContainer = BaseOrbitContainer(middleware) @@ -104,8 +128,8 @@ internal class OrbitSpek : Spek({ middleware = createTestMiddleware { perform("something") .on() - .transform { map { it.action * 2 } } - .transform { map { it * 2 } } + .transform { eventObservable.map { it * 2 } } + .transform { eventObservable.map { it * 2 } } .withReducer { State(currentState.id + event) } } orbitContainer = BaseOrbitContainer(middleware) @@ -132,14 +156,14 @@ internal class OrbitSpek : Spek({ middleware = createTestMiddleware { perform("something") .on() - .transform { map { it.action * 2 } } - .transform { map { it * 2 } } + .transform { eventObservable.map { it * 2 } } + .transform { eventObservable.map { it * 2 } } perform("unlatch") .on() .transform { latch.countDown() - this + eventObservable } } orbitContainer = BaseOrbitContainer(middleware) @@ -167,12 +191,12 @@ internal class OrbitSpek : Spek({ middleware = createTestMiddleware { perform("something") .on() - .transform { map { it.action * 2 } } + .transform { eventObservable.map { it * 2 } } .loopBack { IntModified(event) } - perform("something") + perform("something else") .on() - .transform { map { it.action.value * 2 } } + .transform { eventObservable.map { it.value * 2 } } .withReducer { State(currentState.id + event) } } orbitContainer = BaseOrbitContainer(middleware) @@ -202,12 +226,12 @@ internal class OrbitSpek : Spek({ middleware = createTestMiddleware { perform("something") .on() - .transform { map { it.action * 2 } } + .transform { eventObservable.map { it * 2 } } .withReducer { myReducer(event) } - perform("something") + perform("something else") .on() - .transform { map { it.action + 2 } } + .transform { eventObservable.map { it + 2 } } .withReducer { State(event) } } orbitContainer = BaseOrbitContainer(middleware) @@ -274,153 +298,88 @@ internal class OrbitSpek : Spek({ } } - Scenario("posting side effects as first transformer") { + Scenario("posting side effects") { lateinit var middleware: Middleware lateinit var orbitContainer: BaseOrbitContainer - lateinit var testObserver: TestObserver lateinit var sideEffects: TestObserver Given("A middleware with a single post side effect as the first transformer") { middleware = createTestMiddleware(State(1)) { perform("something") .on() - .sideEffect { post("${event.inputState.id + event.action}") } - .withReducer { currentState.copy(id = currentState.id + 1) } + .sideEffect { post("${currentState.id + event}") } } orbitContainer = BaseOrbitContainer(middleware) } When("sending actions") { - testObserver = orbitContainer.orbit.test() sideEffects = orbitContainer.sideEffect.test() orbitContainer.sendAction(5) - testObserver.awaitCount(2) sideEffects.awaitCount(1) } - Then("produces a correct series of states") { - testObserver.assertValueSequence(listOf(State(1), State(2))) - } - - And("posts a correct series of side effects") { + Then("posts a correct series of side effects") { sideEffects.assertValueSequence(listOf("6")) } } - Scenario("posting side effects as non-first transformer") { - lateinit var middleware: Middleware - lateinit var orbitContainer: BaseOrbitContainer - lateinit var testObserver: TestObserver - lateinit var sideEffects: TestObserver - - Given("A middleware with a single post side effect as the second transformer") { - middleware = createTestMiddleware(State(1)) { - perform("something") - .on() - .transform { map { it.action * 2 } } - .sideEffect { post(event.toString()) } - .withReducer { currentState.copy(id = currentState.id + 1) } - } - orbitContainer = BaseOrbitContainer(middleware) - } - - When("sending actions") { - testObserver = orbitContainer.orbit.test() - sideEffects = orbitContainer.sideEffect.test() - - orbitContainer.sendAction(5) - - testObserver.awaitCount(2) - sideEffects.awaitCount(1) - } - - Then("produces a correct series of states") { - testObserver.assertValueSequence(listOf(State(1), State(2))) - } - - And("posts a correct series of side effects") { - sideEffects.assertValueSequence(listOf("10")) - } - } - - Scenario("non-posting side effects as first transformer") { + Scenario("non-posting side effects") { lateinit var middleware: Middleware lateinit var orbitContainer: BaseOrbitContainer - lateinit var testObserver: TestObserver lateinit var sideEffects: TestObserver - val sideEffectList = mutableListOf() + val testSideEffectRelay = PublishSubject.create() + val testSideEffectObserver = testSideEffectRelay.test() Given("A middleware with a single side effect as the first transformer") { middleware = createTestMiddleware(State(1)) { perform("something") .on() - .sideEffect { sideEffectList.add("${event.inputState.id + event.action}") } - .withReducer { currentState.copy(id = currentState.id + 1) } + .sideEffect { testSideEffectRelay.onNext("${currentState.id + event}") } } orbitContainer = BaseOrbitContainer(middleware) } When("sending actions") { - testObserver = orbitContainer.orbit.test() sideEffects = orbitContainer.sideEffect.test() orbitContainer.sendAction(5) - testObserver.awaitCount(2) - } - - Then("produces a correct series of states") { - testObserver.assertValueSequence(listOf(State(1), State(2))) + testSideEffectObserver.awaitCount(1) } - And("posts no side effects") { + Then("it posts no side effects") { sideEffects.assertNoValues() } And("the side effect is executed") { - assertThat(sideEffectList).containsExactly("6") + testSideEffectObserver.assertValue("6") } } - Scenario("non-posting side effects as non-first transformer") { - lateinit var middleware: Middleware - lateinit var orbitContainer: BaseOrbitContainer - lateinit var testObserver: TestObserver - lateinit var sideEffects: TestObserver - val sideEffectList = mutableListOf() + Scenario("trying to build flows with the same description throw an exception") { + lateinit var flows: OrbitsBuilder.() -> Unit + lateinit var throwable: Throwable - Given("A middleware with a single side effect as the second transformer") { - middleware = createTestMiddleware(State(1)) { + Given("Flows with duplicate flow descriptions") { + flows = { perform("something") .on() - .transform { map { it.action * 2 } } - .sideEffect { sideEffectList.add(event.toString()) } - .withReducer { currentState.copy(id = currentState.id + 1) } - } - orbitContainer = BaseOrbitContainer(middleware) - } - - When("sending actions") { - testObserver = orbitContainer.orbit.test() - sideEffects = orbitContainer.sideEffect.test() - - orbitContainer.sendAction(5) - testObserver.awaitCount(2) - } - - Then("produces a correct series of states") { - testObserver.assertValueSequence(listOf(State(1), State(2))) + perform("something") + .on() + } } - And("posts no side effects") { - sideEffects.assertNoValues() + When("we try to build a middleware using them") { + throwable = Assertions.catchThrowable { createTestMiddleware(block = flows) } } - And("the side effect is executed") { - assertThat(sideEffectList).containsExactly("10") + Then("it throws an exception") { + assertThat(throwable) + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("something") } } } diff --git a/sampleapp/src/main/java/com/babylon/orbit/sample/presentation/TodoScreenTransformer.kt b/sampleapp/src/main/java/com/babylon/orbit/sample/presentation/TodoScreenTransformer.kt index 91abc952..2e7a1af1 100644 --- a/sampleapp/src/main/java/com/babylon/orbit/sample/presentation/TodoScreenTransformer.kt +++ b/sampleapp/src/main/java/com/babylon/orbit/sample/presentation/TodoScreenTransformer.kt @@ -1,6 +1,5 @@ package com.babylon.orbit.sample.presentation -import com.babylon.orbit.ActionState import com.babylon.orbit.sample.domain.todo.GetTodoUseCase import com.babylon.orbit.sample.domain.user.GetUserProfileSwitchesUseCase import com.babylon.orbit.sample.domain.user.GetUserProfileUseCase @@ -13,16 +12,16 @@ class TodoScreenTransformer( private val getUserProfileUseCase: GetUserProfileUseCase ) { - internal fun loadTodos(actions: Observable>) = + internal fun loadTodos(actions: Observable) = actions.switchMap { todoUseCase.getTodoList() } - internal fun loadUserProfileSwitches(actions: Observable>) = - actions.switchMap { actionState -> + internal fun loadUserProfileSwitches(actions: Observable) = + actions.switchMap { event -> getUserProfileSwitchesUseCase.getUserProfileSwitches() - .map { UserProfileExtra(it, actionState.action.userId) } + .map { UserProfileExtra(it, event.userId) } } - internal fun loadUserProfile(actions: Observable>) = - actions.filter { it.action.userProfileSwitchesStatus is UserProfileSwitchesStatus.Result } - .switchMap { getUserProfileUseCase.getUserProfile(it.action.userId) } + internal fun loadUserProfile(actions: Observable) = + actions.filter { it.userProfileSwitchesStatus is UserProfileSwitchesStatus.Result } + .switchMap { getUserProfileUseCase.getUserProfile(it.userId) } } diff --git a/sampleapp/src/main/java/com/babylon/orbit/sample/presentation/TodoViewModel.kt b/sampleapp/src/main/java/com/babylon/orbit/sample/presentation/TodoViewModel.kt index e8cad0ce..2edbc6a3 100644 --- a/sampleapp/src/main/java/com/babylon/orbit/sample/presentation/TodoViewModel.kt +++ b/sampleapp/src/main/java/com/babylon/orbit/sample/presentation/TodoViewModel.kt @@ -14,13 +14,12 @@ class TodoViewModel( LifecycleAction.Created::class.java, TodoScreenAction.RetryAction::class.java ) - .transform { transformers.loadTodos(this) } + .transform { transformers.loadTodos(eventObservable) } .withReducer { reducers.reduceLoadTodos(currentState, event) } perform("track analytics for selected todo") .on() - .sideEffect { sideEffects.trackSelectedTodo(action) } - .ignoringEvents() + .sideEffect { sideEffects.trackSelectedTodo(event) } perform("load the selected todo") .on() @@ -32,12 +31,12 @@ class TodoViewModel( perform("load the user profile switch for the user profile") .on() - .transform { transformers.loadUserProfileSwitches(this) } + .transform { transformers.loadUserProfileSwitches(eventObservable) } .loopBack { event } perform("load the user profile is the switch is on") .on() - .transform { transformers.loadUserProfile(this) } + .transform { transformers.loadUserProfile(eventObservable) } .withReducer { reducers.reduceLoadUserProfile(currentState, event) } perform("handle user profile switch is off") diff --git a/sampleapp/src/test/java/com/babylon/orbit/sample/TodoScreenReducerSpek.kt b/sampleapp/src/test/java/com/babylon/orbit/sample/TodoScreenReducerSpek.kt index 27d3d0fe..6e0a542b 100644 --- a/sampleapp/src/test/java/com/babylon/orbit/sample/TodoScreenReducerSpek.kt +++ b/sampleapp/src/test/java/com/babylon/orbit/sample/TodoScreenReducerSpek.kt @@ -27,7 +27,7 @@ class TodoScreenReducerSpek : Spek({ todoScreenState = reducer.reduceLoadTodos(TodoScreenState(), event) } - Then("should apply the correct state") { + Then("should apply the correct currentState") { assertThat(todoScreenState).isEqualTo( TodoScreenState(screenState = ScreenState.Loading) ) @@ -46,7 +46,7 @@ class TodoScreenReducerSpek : Spek({ todoScreenState = reducer.reduceLoadTodos(TodoScreenState(), event) } - Then("should apply the correct state") { + Then("should apply the correct currentState") { assertThat(todoScreenState).isEqualTo( TodoScreenState(screenState = ScreenState.Error) ) @@ -65,7 +65,7 @@ class TodoScreenReducerSpek : Spek({ todoScreenState = reducer.reduceLoadTodos(TodoScreenState(), event) } - Then("should apply the correct state") { + Then("should apply the correct currentState") { assertThat(todoScreenState).isEqualTo( TodoScreenState(screenState = ScreenState.Ready, todoList = DUMMY_TODO_LIST) ) @@ -85,18 +85,18 @@ class TodoScreenReducerSpek : Spek({ todoScreenState = reducer.reduceLoadSelectedTodo(TodoScreenState(), event) } - Then("should apply the correct state") { + Then("should apply the correct currentState") { assertThat(todoScreenState).isEqualTo( TodoScreenState(todoSelectedId = todoId) ) } } - Scenario("a state with a selected todo event") { + Scenario("a currentState with a selected todo event") { lateinit var todoScreenState: TodoScreenState val todoId = 2 - Given("a state with a selected todo event") { + Given("a currentState with a selected todo event") { todoScreenState = TodoScreenState(todoSelectedId = todoId) } @@ -106,7 +106,7 @@ class TodoScreenReducerSpek : Spek({ ) } - Then("should apply the correct state") { + Then("should apply the correct currentState") { assertThat(todoScreenState).isEqualTo(TodoScreenState()) } } From 33b510ba5432cc59f9deca13f21d17c6451ccd5f Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Tue, 5 Nov 2019 14:58:58 +0100 Subject: [PATCH 04/19] #24, #31 DSL overhaul pt2 --- .../com/babylon/orbit/BaseOrbitContainer.kt | 33 ++++---- orbit/src/main/java/com/babylon/orbit/Dsl.kt | 78 +++++++++---------- .../main/java/com/babylon/orbit/Middleware.kt | 16 ++-- .../test/java/com/babylon/orbit/OrbitSpek.kt | 22 +++--- 4 files changed, 77 insertions(+), 72 deletions(-) diff --git a/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt b/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt index 6e6093d5..42654ac3 100644 --- a/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt +++ b/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt @@ -61,23 +61,28 @@ class BaseOrbitContainer( return this .observeOn(scheduler) .publish { actions -> - Observable.merge( - middleware.orbits.map { transformer -> - transformer( - { currentState }, - actions, - inputRelay + with( + OrbitContext( + { currentState }, + actions, + inputRelay, + false + ) + ) { + Observable.merge( + middleware.orbits.map { transformer -> + transformer() + } + ) + } + .observeOn(scheduler) + .scan(middleware.initialState) { currentState, partialReducer -> + partialReducer( + currentState ) } - ) + .distinctUntilChanged() } - .observeOn(scheduler) - .scan(middleware.initialState) { currentState, partialReducer -> - partialReducer( - currentState - ) - } - .distinctUntilChanged() } private fun createSingleScheduler(): Scheduler { diff --git a/orbit/src/main/java/com/babylon/orbit/Dsl.kt b/orbit/src/main/java/com/babylon/orbit/Dsl.kt index a714073d..82331339 100644 --- a/orbit/src/main/java/com/babylon/orbit/Dsl.kt +++ b/orbit/src/main/java/com/babylon/orbit/Dsl.kt @@ -43,14 +43,11 @@ open class OrbitsBuilder(private val initialStat } inline fun ActionFilter.on() = - this@OrbitsBuilder.Transformer( - description, - false - ) { _, rawActions -> rawActions.ofType() } + this@OrbitsBuilder.Transformer(description) { rawActions.ofType() } @Suppress("unused") // Used for the nice extension function highlight fun ActionFilter.on(vararg classes: Class<*>) = - this@OrbitsBuilder.Transformer(description, false) { _, rawActions -> + this@OrbitsBuilder.Transformer(description) { Observable.merge( classes.map { clazz -> rawActions.filter { clazz.isInstance(it) } } ) @@ -65,25 +62,25 @@ open class OrbitsBuilder(private val initialStat @OrbitDsl inner class Transformer( private val description: String, - private val ioScheduled: Boolean, - private val upstreamTransformer: (() -> STATE, Observable<*>) -> Observable + private val upstreamTransformer: OrbitContext.() -> Observable ) { fun transform(transformer: TransformerReceiver.() -> Observable) = - this@OrbitsBuilder.Transformer(description, true) { currentStateProvider, rawActions -> - val actions = if (ioScheduled) rawActions else rawActions.observeOn(Schedulers.io()) - TransformerReceiver( - currentStateProvider, - upstreamTransformer(currentStateProvider, actions) - ).transformer() + this@OrbitsBuilder.Transformer(description) { + with(switchContextIfNeeded()) { + TransformerReceiver( + currentStateProvider, + upstreamTransformer() + ).transformer() + } } .also { this@OrbitsBuilder.inProgress[description] = it } + fun sideEffect(sideEffect: SideEffectEventReceiver.() -> Unit) = this@OrbitsBuilder.Transformer( - description, - ioScheduled - ) { currentStateProvider, rawActions -> - upstreamTransformer(currentStateProvider, rawActions) + description + ) { + upstreamTransformer() .doOnNext { SideEffectEventReceiver( currentStateProvider, @@ -96,8 +93,8 @@ open class OrbitsBuilder(private val initialStat fun loopBack(mapper: EventReceiver.() -> T) { this@OrbitsBuilder.inProgress.remove(description) - this@OrbitsBuilder.orbits += { currentStateProvider, rawActions, inputRelay -> - upstreamTransformer(currentStateProvider, rawActions) + this@OrbitsBuilder.orbits += { + upstreamTransformer() .doOnNext { action -> inputRelay.onNext( EventReceiver( @@ -112,23 +109,35 @@ open class OrbitsBuilder(private val initialStat } } - fun withReducer(reducer: ReducerReceiver.() -> STATE) { + fun withReducer(reducer: EventReceiver.() -> STATE) { this@OrbitsBuilder.inProgress.remove(description) - this@OrbitsBuilder.orbits += { currentStateProvider, rawActions, _ -> - upstreamTransformer(currentStateProvider, rawActions) + this@OrbitsBuilder.orbits += { + upstreamTransformer() .map { { state: STATE -> - ReducerReceiver(state, it).reducer() + EventReceiver({ state }, it).reducer() } } } } + + private fun OrbitContext.switchContextIfNeeded(): OrbitContext { + return if (ioScheduled) + this + else + OrbitContext( + currentStateProvider, + rawActions.observeOn(Schedulers.io()), + inputRelay, + true + ) + } } fun build() = object : Middleware { init { // Terminates the unterminated chains with a no-op reducer - ArrayList(inProgress.values).forEach { transformer -> transformer.withReducer { currentState } } + ArrayList(inProgress.values).forEach { transformer -> transformer.withReducer { getCurrentState() } } } override val initialState: STATE = this@OrbitsBuilder.initialState @@ -138,33 +147,24 @@ open class OrbitsBuilder(private val initialStat } class TransformerReceiver( - private val currentStateProvider: () -> STATE, + private val stateProvider: () -> STATE, val eventObservable: Observable ) { - val currentState: STATE - get() = currentStateProvider() + fun getCurrentState() = stateProvider() } -class ReducerReceiver( - val currentState: STATE, - val event: EVENT -) - class EventReceiver( - private val currentStateProvider: () -> STATE, + private val stateProvider: () -> STATE, val event: EVENT ) { - val currentState: STATE - get() = currentStateProvider() + fun getCurrentState() = stateProvider() } class SideEffectEventReceiver( - private val currentStateProvider: () -> STATE, + private val stateProvider: () -> STATE, private val sideEffectRelay: Subject, val event: EVENT ) { - val currentState: STATE - get() = currentStateProvider() - + fun getCurrentState() = stateProvider() fun post(sideEffect: SIDE_EFFECT) = sideEffectRelay.onNext(sideEffect) } diff --git a/orbit/src/main/java/com/babylon/orbit/Middleware.kt b/orbit/src/main/java/com/babylon/orbit/Middleware.kt index 89f7df79..60e21ab6 100644 --- a/orbit/src/main/java/com/babylon/orbit/Middleware.kt +++ b/orbit/src/main/java/com/babylon/orbit/Middleware.kt @@ -17,16 +17,16 @@ package com.babylon.orbit import io.reactivex.Observable -import io.reactivex.Scheduler -import io.reactivex.schedulers.Schedulers import io.reactivex.subjects.PublishSubject -import java.util.concurrent.Executors -typealias TransformerFunction = ( - () -> STATE, - Observable<*>, - PublishSubject -) -> (Observable<(STATE) -> STATE>) +typealias TransformerFunction = OrbitContext.() -> (Observable<(STATE) -> STATE>) + +class OrbitContext( + val currentStateProvider: () -> STATE, + val rawActions: Observable<*>, + val inputRelay: PublishSubject, + val ioScheduled: Boolean +) interface Middleware { val initialState: STATE diff --git a/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt b/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt index c716232d..98261d39 100644 --- a/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt +++ b/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt @@ -31,19 +31,19 @@ internal class OrbitSpek : Spek({ perform("something") .on() - .withReducer { currentState.copy(id = currentState.id + event) } + .withReducer { getCurrentState().copy(id = getCurrentState().id + event) } perform("something else") .on() - .loopBack { currentState.id + event } + .loopBack { getCurrentState().id + event } perform("something entirely else") .on() - .sideEffect { println("${currentState.id + event}") } - .transform { eventObservable.map { currentState.id + it + 2 } } + .sideEffect { println("${getCurrentState().id + event}") } + .transform { eventObservable.map { getCurrentState().id + it + 2 } } .sideEffect { println("$event") } .sideEffect { post("$event") } - .withReducer { State(currentState.id + event) } + .withReducer { State(getCurrentState().id + event) } } } @@ -77,7 +77,7 @@ internal class OrbitSpek : Spek({ middleware = createTestMiddleware { perform("something") .on() - .withReducer { State(currentState.id + event) } + .withReducer { State(getCurrentState().id + event) } } orbitContainer = BaseOrbitContainer(middleware) } @@ -103,7 +103,7 @@ internal class OrbitSpek : Spek({ perform("something") .on() .transform { eventObservable.map { it * 2 } } - .withReducer { State(currentState.id + event) } + .withReducer { State(getCurrentState().id + event) } } orbitContainer = BaseOrbitContainer(middleware) } @@ -130,7 +130,7 @@ internal class OrbitSpek : Spek({ .on() .transform { eventObservable.map { it * 2 } } .transform { eventObservable.map { it * 2 } } - .withReducer { State(currentState.id + event) } + .withReducer { State(getCurrentState().id + event) } } orbitContainer = BaseOrbitContainer(middleware) } @@ -197,7 +197,7 @@ internal class OrbitSpek : Spek({ perform("something else") .on() .transform { eventObservable.map { it.value * 2 } } - .withReducer { State(currentState.id + event) } + .withReducer { State(getCurrentState().id + event) } } orbitContainer = BaseOrbitContainer(middleware) } @@ -307,7 +307,7 @@ internal class OrbitSpek : Spek({ middleware = createTestMiddleware(State(1)) { perform("something") .on() - .sideEffect { post("${currentState.id + event}") } + .sideEffect { post("${getCurrentState().id + event}") } } orbitContainer = BaseOrbitContainer(middleware) } @@ -336,7 +336,7 @@ internal class OrbitSpek : Spek({ middleware = createTestMiddleware(State(1)) { perform("something") .on() - .sideEffect { testSideEffectRelay.onNext("${currentState.id + event}") } + .sideEffect { testSideEffectRelay.onNext("${getCurrentState().id + event}") } } orbitContainer = BaseOrbitContainer(middleware) } From 6a5f16f3d268ed3e607883172b8752b3b43ba675 Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Tue, 5 Nov 2019 15:28:07 +0100 Subject: [PATCH 05/19] #24, #31 DSL overhaul pt2 --- orbit/src/main/java/com/babylon/orbit/Dsl.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/orbit/src/main/java/com/babylon/orbit/Dsl.kt b/orbit/src/main/java/com/babylon/orbit/Dsl.kt index 82331339..a21289c1 100644 --- a/orbit/src/main/java/com/babylon/orbit/Dsl.kt +++ b/orbit/src/main/java/com/babylon/orbit/Dsl.kt @@ -26,6 +26,9 @@ fun middleware( }.build() } +@OrbitDsl +class ActionFilter(val description: String) + @OrbitDsl open class OrbitsBuilder(private val initialState: STATE) { // Since this caches unconsumed events we restrict it to one subscriber at a time @@ -53,9 +56,6 @@ open class OrbitsBuilder(private val initialStat ) } - @OrbitDsl - inner class ActionFilter(val description: String) - private val inProgress = mutableMapOf>() private val descriptions = mutableSetOf() From 33c4c704ed93dbef2bceb464feb7afb988709165 Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Tue, 5 Nov 2019 16:33:42 +0100 Subject: [PATCH 06/19] #24, #31 DSL overhaul pt3 --- .../com/babylon/orbit/BaseOrbitContainer.kt | 53 +++++++++-------- orbit/src/main/java/com/babylon/orbit/Dsl.kt | 59 ++++++++----------- .../main/java/com/babylon/orbit/Middleware.kt | 3 +- .../sample/presentation/TodoViewModel.kt | 12 ++-- 4 files changed, 60 insertions(+), 67 deletions(-) diff --git a/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt b/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt index 42654ac3..fba40725 100644 --- a/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt +++ b/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt @@ -30,6 +30,7 @@ class BaseOrbitContainer( ) : OrbitContainer { private val inputRelay: PublishSubject = PublishSubject.create() + private val reducerRelay: PublishSubject<(STATE) -> STATE> = PublishSubject.create() private val disposables = CompositeDisposable() @Volatile @@ -39,26 +40,10 @@ class BaseOrbitContainer( override val sideEffect: Observable = middleware.sideEffect init { - orbit = inputRelay.doOnSubscribe { disposables += it } - .startWith(LifecycleAction.Created) - .buildOrbit(middleware) - .doOnNext { currentState = it } - .replay(1) - - orbit.connect { disposables += it } - } - - override fun sendAction(action: Any) { - inputRelay.onNext(action) - } - - override fun disposeOrbit() { - disposables.clear() - } - - private fun Observable<*>.buildOrbit(middleware: Middleware): Observable { val scheduler = createSingleScheduler() - return this + + disposables += inputRelay.doOnSubscribe { disposables += it } + .startWith(LifecycleAction.Created) .observeOn(scheduler) .publish { actions -> with( @@ -66,6 +51,7 @@ class BaseOrbitContainer( { currentState }, actions, inputRelay, + reducerRelay, false ) ) { @@ -75,14 +61,29 @@ class BaseOrbitContainer( } ) } - .observeOn(scheduler) - .scan(middleware.initialState) { currentState, partialReducer -> - partialReducer( - currentState - ) - } - .distinctUntilChanged() } + .subscribe() + + orbit = reducerRelay + .observeOn(scheduler) + .scan(middleware.initialState) { currentState, partialReducer -> + partialReducer( + currentState + ) + } + .doOnNext { currentState = it } + .distinctUntilChanged() + .replay(1) + + orbit.connect { disposables += it } + } + + override fun sendAction(action: Any) { + inputRelay.onNext(action) + } + + override fun disposeOrbit() { + disposables.clear() } private fun createSingleScheduler(): Scheduler { diff --git a/orbit/src/main/java/com/babylon/orbit/Dsl.kt b/orbit/src/main/java/com/babylon/orbit/Dsl.kt index a21289c1..75d731b9 100644 --- a/orbit/src/main/java/com/babylon/orbit/Dsl.kt +++ b/orbit/src/main/java/com/babylon/orbit/Dsl.kt @@ -56,7 +56,7 @@ open class OrbitsBuilder(private val initialStat ) } - private val inProgress = mutableMapOf>() + private val inProgress = mutableMapOf.() -> Observable<*>>() private val descriptions = mutableSetOf() @OrbitDsl @@ -73,8 +73,7 @@ open class OrbitsBuilder(private val initialStat ).transformer() } } - .also { this@OrbitsBuilder.inProgress[description] = it } - + .also { this@OrbitsBuilder.inProgress[description] = it.upstreamTransformer } fun sideEffect(sideEffect: SideEffectEventReceiver.() -> Unit) = this@OrbitsBuilder.Transformer( @@ -89,11 +88,12 @@ open class OrbitsBuilder(private val initialStat ).sideEffect() } } - .also { this@OrbitsBuilder.inProgress[description] = it } + .also { this@OrbitsBuilder.inProgress[description] = it.upstreamTransformer } - fun loopBack(mapper: EventReceiver.() -> T) { - this@OrbitsBuilder.inProgress.remove(description) - this@OrbitsBuilder.orbits += { + fun loopBack(mapper: EventReceiver.() -> T) = + this@OrbitsBuilder.Transformer( + description + ) { upstreamTransformer() .doOnNext { action -> inputRelay.onNext( @@ -103,45 +103,36 @@ open class OrbitsBuilder(private val initialStat ).mapper() ) } - .map { - { state: STATE -> state } - } - } - } + }.also { this@OrbitsBuilder.inProgress[description] = it.upstreamTransformer } - fun withReducer(reducer: EventReceiver.() -> STATE) { - this@OrbitsBuilder.inProgress.remove(description) - this@OrbitsBuilder.orbits += { + fun withReducer(reducer: EventReceiver.() -> STATE) = + this@OrbitsBuilder.Transformer( + description + ) { upstreamTransformer() - .map { - { state: STATE -> + .doOnNext { + reducerRelay.onNext { state -> EventReceiver({ state }, it).reducer() } } - } - } + }.also { this@OrbitsBuilder.inProgress[description] = it.upstreamTransformer } private fun OrbitContext.switchContextIfNeeded(): OrbitContext { - return if (ioScheduled) - this - else - OrbitContext( - currentStateProvider, - rawActions.observeOn(Schedulers.io()), - inputRelay, - true - ) + return if (ioScheduled) this + else OrbitContext( + currentStateProvider, + rawActions.observeOn(Schedulers.io()), + inputRelay, + reducerRelay, + true + ) } } fun build() = object : Middleware { - init { - // Terminates the unterminated chains with a no-op reducer - ArrayList(inProgress.values).forEach { transformer -> transformer.withReducer { getCurrentState() } } - } - override val initialState: STATE = this@OrbitsBuilder.initialState - override val orbits: List> = this@OrbitsBuilder.orbits + override val orbits: List> = + this@OrbitsBuilder.inProgress.values.toList() override val sideEffect: Observable = sideEffectSubject.hide() } } diff --git a/orbit/src/main/java/com/babylon/orbit/Middleware.kt b/orbit/src/main/java/com/babylon/orbit/Middleware.kt index 60e21ab6..7672a076 100644 --- a/orbit/src/main/java/com/babylon/orbit/Middleware.kt +++ b/orbit/src/main/java/com/babylon/orbit/Middleware.kt @@ -19,12 +19,13 @@ package com.babylon.orbit import io.reactivex.Observable import io.reactivex.subjects.PublishSubject -typealias TransformerFunction = OrbitContext.() -> (Observable<(STATE) -> STATE>) +typealias TransformerFunction = OrbitContext.() -> (Observable<*>) class OrbitContext( val currentStateProvider: () -> STATE, val rawActions: Observable<*>, val inputRelay: PublishSubject, + val reducerRelay: PublishSubject<(STATE) -> STATE>, val ioScheduled: Boolean ) diff --git a/sampleapp/src/main/java/com/babylon/orbit/sample/presentation/TodoViewModel.kt b/sampleapp/src/main/java/com/babylon/orbit/sample/presentation/TodoViewModel.kt index 2edbc6a3..db42d9e8 100644 --- a/sampleapp/src/main/java/com/babylon/orbit/sample/presentation/TodoViewModel.kt +++ b/sampleapp/src/main/java/com/babylon/orbit/sample/presentation/TodoViewModel.kt @@ -15,7 +15,7 @@ class TodoViewModel( TodoScreenAction.RetryAction::class.java ) .transform { transformers.loadTodos(eventObservable) } - .withReducer { reducers.reduceLoadTodos(currentState, event) } + .withReducer { reducers.reduceLoadTodos(getCurrentState(), event) } perform("track analytics for selected todo") .on() @@ -23,11 +23,11 @@ class TodoViewModel( perform("load the selected todo") .on() - .withReducer { reducers.reduceLoadSelectedTodo(currentState, event) } + .withReducer { reducers.reduceLoadSelectedTodo(getCurrentState(), event) } perform("dismiss the selected todo") .on() - .withReducer { reducers.reduceDismissSelectedTodo(currentState) } + .withReducer { reducers.reduceDismissSelectedTodo(getCurrentState()) } perform("load the user profile switch for the user profile") .on() @@ -37,13 +37,13 @@ class TodoViewModel( perform("load the user profile is the switch is on") .on() .transform { transformers.loadUserProfile(eventObservable) } - .withReducer { reducers.reduceLoadUserProfile(currentState, event) } + .withReducer { reducers.reduceLoadUserProfile(getCurrentState(), event) } perform("handle user profile switch is off") .on() - .withReducer { reducers.reduceLoadUserProfileSwitch(currentState, event) } + .withReducer { reducers.reduceLoadUserProfileSwitch(getCurrentState(), event) } perform("dismiss the selected user") .on() - .withReducer { reducers.reduceUserSelected(currentState) } + .withReducer { reducers.reduceUserSelected(getCurrentState()) } }) From 7f0960fe8db60fe9c52b2047d5adfde649635817 Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Wed, 6 Nov 2019 10:24:27 +0100 Subject: [PATCH 07/19] Fixed unit test --- .../babylon/orbit/sample/TodoScreenTransformerSpek.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/sampleapp/src/test/java/com/babylon/orbit/sample/TodoScreenTransformerSpek.kt b/sampleapp/src/test/java/com/babylon/orbit/sample/TodoScreenTransformerSpek.kt index a11b4a6b..ad21e21e 100644 --- a/sampleapp/src/test/java/com/babylon/orbit/sample/TodoScreenTransformerSpek.kt +++ b/sampleapp/src/test/java/com/babylon/orbit/sample/TodoScreenTransformerSpek.kt @@ -1,10 +1,8 @@ package com.babylon.orbit.sample -import com.babylon.orbit.ActionState import com.babylon.orbit.LifecycleAction import com.babylon.orbit.sample.domain.todo.GetTodoUseCase import com.babylon.orbit.sample.presentation.TodoScreenAction -import com.babylon.orbit.sample.presentation.TodoScreenState import com.babylon.orbit.sample.presentation.TodoScreenTransformer import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify @@ -29,7 +27,7 @@ class TodoScreenTransformerSpek : Spek({ Given("an $event") {} When("passing the event named ${event.javaClass} into the transformer") { - transformer.loadTodos(createActionState(TodoScreenState(), event)).test() + transformer.loadTodos(Observable.just(event)).test() } Then("should trigger the correct action") { @@ -39,8 +37,3 @@ class TodoScreenTransformerSpek : Spek({ } } }) - -private fun createActionState(action: ACTION, state: STATE) = - Observable.just( - ActionState(action, state) - ) From 412ced1d30b478ecb88bd50c758bf5d46a0acdaf Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Sat, 9 Nov 2019 16:42:12 +0100 Subject: [PATCH 08/19] Further DSL simplifications --- docs/orbits.md | 4 +- .../com/babylon/orbit/BaseOrbitContainer.kt | 18 ++-- orbit/src/main/java/com/babylon/orbit/Dsl.kt | 91 ++++++++----------- .../main/java/com/babylon/orbit/Middleware.kt | 12 +-- 4 files changed, 57 insertions(+), 68 deletions(-) diff --git a/docs/orbits.md b/docs/orbits.md index 5d91bd9e..cd9de4c5 100644 --- a/docs/orbits.md +++ b/docs/orbits.md @@ -7,8 +7,8 @@ the glue between small, distinct functions. ``` kotlin perform("add random number") .on() - .transform { this.compose(getRandomNumberUseCase) } - .withReducer { state.copy(currentState.total + event.number) } + .transform { eventObservable.compose(getRandomNumberUseCase) } + .withReducer { getCurrentState().copy(currentState.total + event.number) } ``` We can break an orbit into its constituent parts to be able to understand it diff --git a/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt b/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt index fba40725..821711bd 100644 --- a/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt +++ b/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt @@ -29,20 +29,21 @@ class BaseOrbitContainer( middleware: Middleware ) : OrbitContainer { - private val inputRelay: PublishSubject = PublishSubject.create() - private val reducerRelay: PublishSubject<(STATE) -> STATE> = PublishSubject.create() + private val inputSubject: PublishSubject = PublishSubject.create() + private val reducerSubject: PublishSubject<(STATE) -> STATE> = PublishSubject.create() + private val sideEffectSubject: PublishSubject = PublishSubject.create() private val disposables = CompositeDisposable() @Volatile override var currentState: STATE = middleware.initialState private set override val orbit: ConnectableObservable - override val sideEffect: Observable = middleware.sideEffect + override val sideEffect: Observable = sideEffectSubject.hide() init { val scheduler = createSingleScheduler() - disposables += inputRelay.doOnSubscribe { disposables += it } + disposables += inputSubject.doOnSubscribe { disposables += it } .startWith(LifecycleAction.Created) .observeOn(scheduler) .publish { actions -> @@ -50,8 +51,9 @@ class BaseOrbitContainer( OrbitContext( { currentState }, actions, - inputRelay, - reducerRelay, + inputSubject, + reducerSubject, + sideEffectSubject, false ) ) { @@ -64,7 +66,7 @@ class BaseOrbitContainer( } .subscribe() - orbit = reducerRelay + orbit = reducerSubject .observeOn(scheduler) .scan(middleware.initialState) { currentState, partialReducer -> partialReducer( @@ -79,7 +81,7 @@ class BaseOrbitContainer( } override fun sendAction(action: Any) { - inputRelay.onNext(action) + inputSubject.onNext(action) } override fun disposeOrbit() { diff --git a/orbit/src/main/java/com/babylon/orbit/Dsl.kt b/orbit/src/main/java/com/babylon/orbit/Dsl.kt index 75d731b9..a86c5113 100644 --- a/orbit/src/main/java/com/babylon/orbit/Dsl.kt +++ b/orbit/src/main/java/com/babylon/orbit/Dsl.kt @@ -1,6 +1,5 @@ package com.babylon.orbit -import hu.akarnokd.rxjava2.subjects.UnicastWorkSubject import io.reactivex.Observable import io.reactivex.rxkotlin.ofType import io.reactivex.schedulers.Schedulers @@ -31,10 +30,9 @@ class ActionFilter(val description: String) @OrbitDsl open class OrbitsBuilder(private val initialState: STATE) { - // Since this caches unconsumed events we restrict it to one subscriber at a time - protected val sideEffectSubject: Subject = UnicastWorkSubject.create() - - private val orbits = mutableListOf>() + private val orbits = + mutableMapOf.() -> Observable<*>>() + private val descriptions = mutableSetOf() @Suppress("unused") // Used for the nice extension function highlight fun OrbitsBuilder.perform(description: String) = ActionFilter(description) @@ -56,84 +54,73 @@ open class OrbitsBuilder(private val initialStat ) } - private val inProgress = mutableMapOf.() -> Observable<*>>() - private val descriptions = mutableSetOf() - @OrbitDsl inner class Transformer( private val description: String, - private val upstreamTransformer: OrbitContext.() -> Observable + private val upstreamTransformer: OrbitContext.() -> Observable ) { fun transform(transformer: TransformerReceiver.() -> Observable) = this@OrbitsBuilder.Transformer(description) { - with(switchContextIfNeeded()) { + val newContext = switchContextIfNeeded() + val upstream = if (this != newContext) { + upstreamTransformer().observeOn(Schedulers.io()) + } else upstreamTransformer() + + with(newContext) { TransformerReceiver( currentStateProvider, - upstreamTransformer() + upstream ).transformer() } } - .also { this@OrbitsBuilder.inProgress[description] = it.upstreamTransformer } + .also { this@OrbitsBuilder.orbits[description] = it.upstreamTransformer } fun sideEffect(sideEffect: SideEffectEventReceiver.() -> Unit) = - this@OrbitsBuilder.Transformer( - description - ) { - upstreamTransformer() - .doOnNext { - SideEffectEventReceiver( - currentStateProvider, - this@OrbitsBuilder.sideEffectSubject, - it - ).sideEffect() - } + doOnNextTransformer { event -> + SideEffectEventReceiver( + currentStateProvider, + sideEffectSubject, + event + ).sideEffect() } - .also { this@OrbitsBuilder.inProgress[description] = it.upstreamTransformer } fun loopBack(mapper: EventReceiver.() -> T) = - this@OrbitsBuilder.Transformer( - description - ) { - upstreamTransformer() - .doOnNext { action -> - inputRelay.onNext( - EventReceiver( - currentStateProvider, - action - ).mapper() - ) - } - }.also { this@OrbitsBuilder.inProgress[description] = it.upstreamTransformer } + doOnNextTransformer { event -> + inputSubject.onNext( + EventReceiver( + currentStateProvider, + event + ).mapper() + ) + } fun withReducer(reducer: EventReceiver.() -> STATE) = + doOnNextTransformer { event -> + reducerSubject.onNext { state -> + EventReceiver({ state }, event).reducer() + } + } + + private fun doOnNextTransformer(func: OrbitContext.(EVENT) -> Unit) = this@OrbitsBuilder.Transformer( description ) { upstreamTransformer() .doOnNext { - reducerRelay.onNext { state -> - EventReceiver({ state }, it).reducer() - } + func(it) } - }.also { this@OrbitsBuilder.inProgress[description] = it.upstreamTransformer } + }.also { this@OrbitsBuilder.orbits[description] = it.upstreamTransformer } - private fun OrbitContext.switchContextIfNeeded(): OrbitContext { + private fun OrbitContext.switchContextIfNeeded(): OrbitContext { return if (ioScheduled) this - else OrbitContext( - currentStateProvider, - rawActions.observeOn(Schedulers.io()), - inputRelay, - reducerRelay, - true - ) + else copy(ioScheduled = true) } } fun build() = object : Middleware { override val initialState: STATE = this@OrbitsBuilder.initialState - override val orbits: List> = - this@OrbitsBuilder.inProgress.values.toList() - override val sideEffect: Observable = sideEffectSubject.hide() + override val orbits: List> = + this@OrbitsBuilder.orbits.values.toList() } } diff --git a/orbit/src/main/java/com/babylon/orbit/Middleware.kt b/orbit/src/main/java/com/babylon/orbit/Middleware.kt index 7672a076..3b036448 100644 --- a/orbit/src/main/java/com/babylon/orbit/Middleware.kt +++ b/orbit/src/main/java/com/babylon/orbit/Middleware.kt @@ -19,18 +19,18 @@ package com.babylon.orbit import io.reactivex.Observable import io.reactivex.subjects.PublishSubject -typealias TransformerFunction = OrbitContext.() -> (Observable<*>) +typealias TransformerFunction = OrbitContext.() -> (Observable<*>) -class OrbitContext( +data class OrbitContext( val currentStateProvider: () -> STATE, val rawActions: Observable<*>, - val inputRelay: PublishSubject, - val reducerRelay: PublishSubject<(STATE) -> STATE>, + val inputSubject: PublishSubject, + val reducerSubject: PublishSubject<(STATE) -> STATE>, + val sideEffectSubject: PublishSubject, val ioScheduled: Boolean ) interface Middleware { val initialState: STATE - val orbits: List> - val sideEffect: Observable + val orbits: List> } From 16a52edb725ba3dcabfdfc335778006153449a47 Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Sat, 9 Nov 2019 17:57:30 +0100 Subject: [PATCH 09/19] Further DSL simplifications --- orbit-android/orbit-android_build.gradle.kts | 4 + .../orbit/AndroidOrbitContainerSpek.kt | 11 ++ .../com/babylon/orbit/OrbitViewModelSpek.kt | 23 +++ .../test/java/com/babylon/orbit/OrbitSpek.kt | 174 ++++++++++-------- .../test/java/com/babylon/orbit/TestUtil.kt | 10 + 5 files changed, 143 insertions(+), 79 deletions(-) create mode 100644 orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt create mode 100644 orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelSpek.kt create mode 100644 orbit/src/test/java/com/babylon/orbit/TestUtil.kt diff --git a/orbit-android/orbit-android_build.gradle.kts b/orbit-android/orbit-android_build.gradle.kts index 9d4423e3..404ac520 100644 --- a/orbit-android/orbit-android_build.gradle.kts +++ b/orbit-android/orbit-android_build.gradle.kts @@ -64,4 +64,8 @@ dependencies { implementation(ProjectDependencies.rxAndroid) implementation(ProjectDependencies.autodispose) implementation(ProjectDependencies.autodisposeArchComponents) + + // Testing + GroupedDependencies.spekTestsImplementation.forEach { testImplementation(it) } + GroupedDependencies.spekTestsRuntime.forEach { testRuntimeOnly(it) } } diff --git a/orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt b/orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt new file mode 100644 index 00000000..aad45a2b --- /dev/null +++ b/orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt @@ -0,0 +1,11 @@ +package com.babylon.orbit + +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.gherkin.Feature + +class AndroidOrbitContainerSpek : Spek({ + Feature("Android Container - Threading") { + Scenario("Side effects are received on the android main thread") {} + Scenario("State updates are received on the android main thread") {} + } +}) \ No newline at end of file diff --git a/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelSpek.kt b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelSpek.kt new file mode 100644 index 00000000..3b89ed96 --- /dev/null +++ b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelSpek.kt @@ -0,0 +1,23 @@ +package com.babylon.orbit + +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.gherkin.Feature + +class OrbitViewModelSpek : Spek({ + Feature("View Model - State") { + Scenario("The current state can be queried") {} + } + Feature("View Model - Lifecycle") { + Scenario("If I connect in onCreate I get disconnected in onDestroy") {} + Scenario("If I connect in onStart I get disconnected in onStop") {} + Scenario("If I connect in onResume I get disconnected in onPause") {} + Scenario("If I connect in methods other than onCreate/onStart/onResume I get an exception") {} + // TODO think if above makes sense in context of fragment lifecycle + } + Feature("View Model - Connection") { + Scenario("Actions are delivered to the orbit container even if view is not connected") {} + Scenario("I receive state updates and side effects when connected") {} + Scenario("I do not receive state updates and side effects when disconnected") {} + Scenario("Instance of view is not retained after disconnection") {} // How to test this + } +}) \ No newline at end of file diff --git a/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt b/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt index 98261d39..dcb6c788 100644 --- a/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt +++ b/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt @@ -26,7 +26,7 @@ import java.util.concurrent.CountDownLatch internal class OrbitSpek : Spek({ - Feature("Orbit DSL syntax") { + Feature("DSL - syntax") { createTestMiddleware { perform("something") @@ -43,41 +43,23 @@ internal class OrbitSpek : Spek({ .transform { eventObservable.map { getCurrentState().id + it + 2 } } .sideEffect { println("$event") } .sideEffect { post("$event") } - .withReducer { State(getCurrentState().id + event) } + .withReducer { TestState(getCurrentState().id + event) } + .transform { eventObservable.map { getCurrentState().id + it + 2 } } } } - Feature("Orbit DSL tests") { - - Scenario("no flows") { - lateinit var middleware: Middleware - lateinit var orbitContainer: BaseOrbitContainer - lateinit var testObserver: TestObserver - - Given("A middleware with no flows") { - middleware = createTestMiddleware {} - orbitContainer = BaseOrbitContainer(middleware) - } - - When("connecting to the middleware") { - testObserver = orbitContainer.orbit.test() - } - - Then("emits the initial state") { - testObserver.assertValueSequence(listOf(middleware.initialState)) - } - } + Feature("DSL - tests") { Scenario("a flow that reduces an action") { - lateinit var middleware: Middleware - lateinit var orbitContainer: BaseOrbitContainer - lateinit var testObserver: TestObserver + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var testObserver: TestObserver Given("A middleware with one reducer flow") { middleware = createTestMiddleware { perform("something") .on() - .withReducer { State(getCurrentState().id + event) } + .withReducer { TestState(getCurrentState().id + event) } } orbitContainer = BaseOrbitContainer(middleware) } @@ -89,21 +71,21 @@ internal class OrbitSpek : Spek({ Then("produces a correct end state") { testObserver.awaitCount(2) - testObserver.assertValueSequence(listOf(State(42), State(47))) + testObserver.assertValueSequence(listOf(TestState(42), TestState(47))) } } Scenario("a flow with a transformer and reducer") { - lateinit var middleware: Middleware - lateinit var orbitContainer: BaseOrbitContainer - lateinit var testObserver: TestObserver + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var testObserver: TestObserver Given("A middleware with a transformer and reducer") { middleware = createTestMiddleware { perform("something") .on() .transform { eventObservable.map { it * 2 } } - .withReducer { State(getCurrentState().id + event) } + .withReducer { TestState(getCurrentState().id + event) } } orbitContainer = BaseOrbitContainer(middleware) } @@ -115,14 +97,14 @@ internal class OrbitSpek : Spek({ Then("produces a correct end state") { testObserver.awaitCount(2) - testObserver.assertValueSequence(listOf(State(42), State(52))) + testObserver.assertValueSequence(listOf(TestState(42), TestState(52))) } } Scenario("a flow with two transformers and a reducer") { - lateinit var middleware: Middleware - lateinit var orbitContainer: BaseOrbitContainer - lateinit var testObserver: TestObserver + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var testObserver: TestObserver Given("A middleware with two transformers and a reducer") { middleware = createTestMiddleware { @@ -130,7 +112,7 @@ internal class OrbitSpek : Spek({ .on() .transform { eventObservable.map { it * 2 } } .transform { eventObservable.map { it * 2 } } - .withReducer { State(getCurrentState().id + event) } + .withReducer { TestState(getCurrentState().id + event) } } orbitContainer = BaseOrbitContainer(middleware) } @@ -142,15 +124,15 @@ internal class OrbitSpek : Spek({ Then("produces a correct end state") { testObserver.awaitCount(2) - testObserver.assertValueSequence(listOf(State(42), State(62))) + testObserver.assertValueSequence(listOf(TestState(42), TestState(62))) } } - Scenario("a flow with two transformers that is ignored") { + Scenario("a flow with two transformers and no reducer") { val latch = CountDownLatch(1) - lateinit var middleware: Middleware - lateinit var orbitContainer: BaseOrbitContainer - lateinit var testObserver: TestObserver + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var testObserver: TestObserver Given("A middleware with two transformer flows") { middleware = createTestMiddleware { @@ -176,16 +158,16 @@ internal class OrbitSpek : Spek({ } Then("emits just the initial state after connecting") { - testObserver.assertValueSequence(listOf(State(42))) + testObserver.assertValueSequence(listOf(TestState(42))) } } Scenario("a flow with a transformer loopback and a flow with a transformer and reducer") { data class IntModified(val value: Int) - lateinit var middleware: Middleware - lateinit var orbitContainer: BaseOrbitContainer - lateinit var testObserver: TestObserver + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var testObserver: TestObserver Given("A middleware with a transformer loopback flow and transform/reduce flow") { middleware = createTestMiddleware { @@ -197,7 +179,7 @@ internal class OrbitSpek : Spek({ perform("something else") .on() .transform { eventObservable.map { it.value * 2 } } - .withReducer { State(getCurrentState().id + event) } + .withReducer { TestState(getCurrentState().id + event) } } orbitContainer = BaseOrbitContainer(middleware) } @@ -209,17 +191,17 @@ internal class OrbitSpek : Spek({ Then("produces a correct end state") { testObserver.awaitCount(2) - testObserver.assertValueSequence(listOf(State(42), State(62))) + testObserver.assertValueSequence(listOf(TestState(42), TestState(62))) } } Scenario("a flow with two transformers with reducers") { - lateinit var middleware: Middleware - lateinit var orbitContainer: BaseOrbitContainer - lateinit var testObserver: TestObserver + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var testObserver: TestObserver - fun myReducer(event: Int): State { - return State(event) + fun myReducer(event: Int): TestState { + return TestState(event) } Given("A middleware with two transform/reduce flows") { @@ -232,7 +214,7 @@ internal class OrbitSpek : Spek({ perform("something else") .on() .transform { eventObservable.map { it + 2 } } - .withReducer { State(event) } + .withReducer { TestState(event) } } orbitContainer = BaseOrbitContainer(middleware) } @@ -244,7 +226,7 @@ internal class OrbitSpek : Spek({ Then("produces a correct series of states") { testObserver.awaitCount(3) - testObserver.assertValueSet(listOf(State(42), State(10), State(7))) + testObserver.assertValueSet(listOf(TestState(42), TestState(10), TestState(7))) } } Scenario("a flow with three transformers with reducers") { @@ -253,24 +235,24 @@ internal class OrbitSpek : Spek({ class Two class Three - lateinit var middleware: Middleware - lateinit var orbitContainer: BaseOrbitContainer - lateinit var testObserver: TestObserver - val expectedOutput = mutableListOf(State(0)) + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var testObserver: TestObserver + val expectedOutput = mutableListOf(TestState(0)) Given("A middleware with three transform/reduce flows") { - middleware = createTestMiddleware(State(0)) { + middleware = createTestMiddleware(TestState(0)) { perform("one") .on() - .withReducer { State(1) } + .withReducer { TestState(1) } perform("two") .on() - .withReducer { State(2) } + .withReducer { TestState(2) } perform("three") .on() - .withReducer { State(3) } + .withReducer { TestState(3) } } orbitContainer = BaseOrbitContainer(middleware) } @@ -279,7 +261,7 @@ internal class OrbitSpek : Spek({ testObserver = orbitContainer.orbit.test() for (i in 0 until 99) { val value = (i % 3) - expectedOutput.add(State(value + 1)) + expectedOutput.add(TestState(value + 1)) orbitContainer.sendAction( when (value) { @@ -299,12 +281,12 @@ internal class OrbitSpek : Spek({ } Scenario("posting side effects") { - lateinit var middleware: Middleware - lateinit var orbitContainer: BaseOrbitContainer + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer lateinit var sideEffects: TestObserver Given("A middleware with a single post side effect as the first transformer") { - middleware = createTestMiddleware(State(1)) { + middleware = createTestMiddleware(TestState(1)) { perform("something") .on() .sideEffect { post("${getCurrentState().id + event}") } @@ -326,14 +308,14 @@ internal class OrbitSpek : Spek({ } Scenario("non-posting side effects") { - lateinit var middleware: Middleware - lateinit var orbitContainer: BaseOrbitContainer + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer lateinit var sideEffects: TestObserver val testSideEffectRelay = PublishSubject.create() val testSideEffectObserver = testSideEffectRelay.test() Given("A middleware with a single side effect as the first transformer") { - middleware = createTestMiddleware(State(1)) { + middleware = createTestMiddleware(TestState(1)) { perform("something") .on() .sideEffect { testSideEffectRelay.onNext("${getCurrentState().id + event}") } @@ -359,7 +341,7 @@ internal class OrbitSpek : Spek({ } Scenario("trying to build flows with the same description throw an exception") { - lateinit var flows: OrbitsBuilder.() -> Unit + lateinit var flows: OrbitsBuilder.() -> Unit lateinit var throwable: Throwable Given("Flows with duplicate flow descriptions") { @@ -383,15 +365,49 @@ internal class OrbitSpek : Spek({ } } } -}) -private fun createTestMiddleware( - initialState: State = State(42), - block: OrbitsBuilder.() -> Unit -) = middleware(initialState) { - this.apply(block) -} + Feature("Container - State") { -private data class State(val id: Int) + Scenario("Initial state is always emitted") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var testObserver: TestObserver -private const val AWAIT_TIMEOUT = 10000L + Given("A middleware with no flows") { + middleware = createTestMiddleware {} + orbitContainer = BaseOrbitContainer(middleware) + } + + When("connecting to the middleware") { + testObserver = orbitContainer.orbit.test() + } + + Then("emits the initial state") { + testObserver.assertValueSequence(listOf(middleware.initialState)) + } + } + + Scenario("Current state always emitted upon subscription") {} + Scenario("Updated state is emitted after it changes while nothing is connected") {} + Scenario("Current state can be queried directly after modification") {} + } + + Feature("Container - Side Effects") { + Scenario("Side effects are multicast to all current observers") {} + Scenario("Side effects are cached while there is no connected observer") {} // Is this the responsibility of this library? + Scenario("Cached side effects are guaranteed to be delivered to the first observer") {} + Scenario("Cached side effects are not guaranteed to be delivered to observers beyond the first") {} + } + + Feature("Container - Threading") { + Scenario("Side effects execute on the current thread (before a tranform - reducer thread)") {} + Scenario("Reducers execute on reducer thread") {} + Scenario("Transformer executes on IO thread") {} + Scenario("The downstream transformers and side effects of a transformer execute on IO thread") {} + Scenario("The downstream reducers of a transformer executes on reducer thread") {} + } + + Feature("Container - Lifecycle") { + Scenario("Lifecycle action sent on container creation") {} + } +}) diff --git a/orbit/src/test/java/com/babylon/orbit/TestUtil.kt b/orbit/src/test/java/com/babylon/orbit/TestUtil.kt new file mode 100644 index 00000000..73ad648a --- /dev/null +++ b/orbit/src/test/java/com/babylon/orbit/TestUtil.kt @@ -0,0 +1,10 @@ +package com.babylon.orbit + +fun createTestMiddleware( + initialState: TestState = TestState(42), + block: OrbitsBuilder.() -> Unit +) = middleware(initialState) { + this.apply(block) +} + +data class TestState(val id: Int) \ No newline at end of file From 9d4fff2cdd1d1c124b45c6ca93f73921df455af1 Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Sun, 10 Nov 2019 12:14:22 +0100 Subject: [PATCH 10/19] #25 Added more unit tests --- .../src/main/kotlin/DependencyManagement.kt | 2 +- .../orbit/AndroidOrbitContainerSpek.kt | 52 +++- .../test/java/com/babylon/orbit/TestUtil.kt | 10 + .../com/babylon/orbit/BaseOrbitContainer.kt | 2 +- .../com/babylon/orbit/OrbitContainerSpek.kt | 294 ++++++++++++++++++ .../orbit/OrbitContainerThreadingSpek.kt | 284 +++++++++++++++++ .../orbit/{OrbitSpek.kt => OrbitDSLSpek.kt} | 48 +-- 7 files changed, 642 insertions(+), 50 deletions(-) create mode 100644 orbit-android/src/test/java/com/babylon/orbit/TestUtil.kt create mode 100644 orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt create mode 100644 orbit/src/test/java/com/babylon/orbit/OrbitContainerThreadingSpek.kt rename orbit/src/test/java/com/babylon/orbit/{OrbitSpek.kt => OrbitDSLSpek.kt} (87%) diff --git a/buildSrc/src/main/kotlin/DependencyManagement.kt b/buildSrc/src/main/kotlin/DependencyManagement.kt index aac3049b..68078507 100644 --- a/buildSrc/src/main/kotlin/DependencyManagement.kt +++ b/buildSrc/src/main/kotlin/DependencyManagement.kt @@ -43,7 +43,7 @@ object Versions { const val groupie = "2.6.0" // Testing - const val spek = "2.0.7" + const val spek = "2.0.8" const val junitPlatform = "1.5.2" const val assertJ = "3.13.2" const val mockitoKotlin = "2.1.0" diff --git a/orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt b/orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt index aad45a2b..64cff9b7 100644 --- a/orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt +++ b/orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt @@ -1,11 +1,59 @@ package com.babylon.orbit +import io.reactivex.android.plugins.RxAndroidPlugins +import io.reactivex.observers.TestObserver +import io.reactivex.plugins.RxJavaPlugins +import org.assertj.core.api.Assertions.assertThat import org.spekframework.spek2.Spek import org.spekframework.spek2.style.gherkin.Feature class AndroidOrbitContainerSpek : Spek({ + + beforeGroup { + RxAndroidPlugins.setInitMainThreadSchedulerHandler { + RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } + } + RxAndroidPlugins.setMainThreadSchedulerHandler { + RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } + } + } + + afterGroup { + RxJavaPlugins.reset() + } + Feature("Android Container - Threading") { - Scenario("Side effects are received on the android main thread") {} - Scenario("State updates are received on the android main thread") {} + Scenario("Side effects and state updates are received on the android main thread") { + lateinit var middleware: Middleware + lateinit var orbitContainer: AndroidOrbitContainer + lateinit var stateObserver: TestObserver + lateinit var sideEffectObserver: TestObserver + + Given("A middleware with no flows") { + middleware = createTestMiddleware { + perform("send side effect") + .on() + .sideEffect { post("foo") } + .sideEffect { post("bar") } + .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + } + orbitContainer = AndroidOrbitContainer(middleware) + } + + When("I send an event to the container") { + stateObserver = orbitContainer.orbit.test() + sideEffectObserver = orbitContainer.sideEffect.test() + orbitContainer.sendAction(Unit) + stateObserver.awaitCount(2) + } + + Then("The state observer listens on the android main thread") { + assertThat(stateObserver.lastThread().name).isEqualTo("main") + } + + And("The side effect observer listens on the android main thread") { + assertThat(sideEffectObserver.lastThread().name).isEqualTo("main") + } + } } }) \ No newline at end of file diff --git a/orbit-android/src/test/java/com/babylon/orbit/TestUtil.kt b/orbit-android/src/test/java/com/babylon/orbit/TestUtil.kt new file mode 100644 index 00000000..73ad648a --- /dev/null +++ b/orbit-android/src/test/java/com/babylon/orbit/TestUtil.kt @@ -0,0 +1,10 @@ +package com.babylon.orbit + +fun createTestMiddleware( + initialState: TestState = TestState(42), + block: OrbitsBuilder.() -> Unit +) = middleware(initialState) { + this.apply(block) +} + +data class TestState(val id: Int) \ No newline at end of file diff --git a/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt b/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt index 821711bd..33eefdf2 100644 --- a/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt +++ b/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt @@ -89,6 +89,6 @@ class BaseOrbitContainer( } private fun createSingleScheduler(): Scheduler { - return Schedulers.from(Executors.newSingleThreadExecutor()) + return Schedulers.from(Executors.newSingleThreadExecutor { Thread(it, "reducerThread") }) } } diff --git a/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt b/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt new file mode 100644 index 00000000..c3533e73 --- /dev/null +++ b/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt @@ -0,0 +1,294 @@ +/* + * Copyright 2019 Babylon Partners Limited + * + * 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 + * + * http://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. + */ + +package com.babylon.orbit + +import io.reactivex.observers.TestObserver +import org.assertj.core.api.Assertions.assertThat +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.gherkin.Feature + +internal class OrbitContainerSpek : Spek({ + Feature("Container - State") { + Scenario("Initial state is always emitted") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var testObserver: TestObserver + + Given("A middleware with no flows") { + middleware = createTestMiddleware {} + orbitContainer = BaseOrbitContainer(middleware) + } + + When("I connect to the middleware") { + testObserver = orbitContainer.orbit.test() + } + + Then("emits the initial state") { + testObserver.assertValueSequence(listOf(middleware.initialState)) + } + } + + Scenario("Current state always emitted upon subscription") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var testObserver1: TestObserver + lateinit var testObserver2: TestObserver + + Given("A middleware with no flows") { + middleware = createTestMiddleware {} + orbitContainer = BaseOrbitContainer(middleware) + } + + When("I connect observer 1 to the middleware") { + testObserver1 = orbitContainer.orbit.test() + testObserver1.awaitCount(1) + } + + And("I connect observer 2 to the middleware") { + testObserver2 = orbitContainer.orbit.test() + testObserver1.awaitCount(1) + } + + Then("Observer 1 gets the initial state") { + testObserver1.assertValueSequence(listOf(middleware.initialState)) + } + + And("Observer 2 gets the initial state") { + testObserver2.assertValueSequence(listOf(middleware.initialState)) + } + } + + Scenario("Updated state is emitted on connection after it changes while disconnected") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var testObserver1: TestObserver + lateinit var testObserver2: TestObserver + + Given("A middleware with no flows") { + middleware = createTestMiddleware { + perform("increment id") + .on() + .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("I send an increment event to the flow") { + val awaitObserver = orbitContainer.orbit.test() + orbitContainer.sendAction(Unit) + awaitObserver.awaitCount(2) + awaitObserver.dispose() + } + + And("I connect observer 1 to the middleware") { + testObserver1 = orbitContainer.orbit.test() + testObserver1.awaitCount(1) + } + + And("I connect observer 2 to the middleware") { + testObserver2 = orbitContainer.orbit.test() + testObserver1.awaitCount(1) + } + + Then("Observer 1 gets the modified state") { + testObserver1.assertValueSequence(listOf(TestState(43))) + } + + And("Observer 2 gets the modified state") { + testObserver2.assertValueSequence(listOf(TestState(43))) + } + } + + Scenario("Current state can be queried directly before and after modification") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var state1: TestState + lateinit var state2: TestState + + Given("A middleware with no flows") { + middleware = createTestMiddleware { + perform("increment id") + .on() + .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("I query the state") { + state1 = orbitContainer.currentState + } + + And("I send an increment event to the flow") { + val awaitObserver = orbitContainer.orbit.test() + orbitContainer.sendAction(Unit) + awaitObserver.awaitCount(2) + awaitObserver.dispose() + } + + And("I query the state") { + state2 = orbitContainer.currentState + } + + Then("State 1 is the initial state") { + assertThat(state1).isEqualTo(TestState(42)) + } + + And("State 2 is the modified state") { + assertThat(state2).isEqualTo(TestState(43)) + } + } + } + + Feature("Container - Side Effects") { + Scenario("Side effects are multicast to all current observers") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var testObserver1: TestObserver + lateinit var testObserver2: TestObserver + + Given("A middleware with no flows") { + middleware = createTestMiddleware { + perform("send side effect") + .on() + .sideEffect { post("foobar") } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("I connect observer 1 to the middleware") { + testObserver1 = orbitContainer.sideEffect.test() + } + + And("I connect observer 2 to the middleware") { + testObserver2 = orbitContainer.sideEffect.test() + } + + And("I send an event to the container") { + orbitContainer.sendAction(Unit) + testObserver1.awaitCount(1) + testObserver2.awaitCount(1) + } + + Then("Observer 1 gets the side effect") { + testObserver1.assertValueSequence(listOf("foobar")) + } + + And("Observer 2 gets the side effect") { + testObserver2.assertValueSequence(listOf("foobar")) + } + } + + // Is this the responsibility of this library? + Scenario("Side effects are cached while there is no connected observer ") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var stateObserver: TestObserver + lateinit var sideEffectObserver: TestObserver + + Given("A middleware with no flows") { + middleware = createTestMiddleware { + perform("send side effect") + .on() + .sideEffect { post("foo") } + .sideEffect { post("bar") } + .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("I send an event to the container") { + stateObserver = orbitContainer.orbit.test() + orbitContainer.sendAction(Unit) + stateObserver.awaitCount(2) + } + + And("I connect the side effect observer to the middleware") { + sideEffectObserver = orbitContainer.sideEffect.test() + sideEffectObserver.awaitCount(1) + } + + Then("The observer gets the side effect") { + sideEffectObserver.assertValueSequence(listOf("foo", "bar")) + } + } + + Scenario("Cached side effects are guaranteed to be delivered to the first observer") { + + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var stateObserver: TestObserver + lateinit var sideEffectObserver1: TestObserver + lateinit var sideEffectObserver2: TestObserver + + Given("A middleware with no flows") { + middleware = createTestMiddleware { + perform("send side effect") + .on() + .sideEffect { post("foo") } + .sideEffect { post("bar") } + .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("I send an event to the container") { + stateObserver = orbitContainer.orbit.test() + orbitContainer.sendAction(Unit) + stateObserver.awaitCount(2) + } + + And("I connect both side effect observers to the middleware") { + sideEffectObserver1 = orbitContainer.sideEffect.test() + sideEffectObserver2 = orbitContainer.sideEffect.test() + sideEffectObserver1.awaitCount(2) + } + + Then("The first observer gets all cached side effects") { + sideEffectObserver1.assertValueSequence(listOf("foo", "bar")) + } + + And("The second observer is not guaranteed to get any side effects") { + assertThat(sideEffectObserver2.values()).doesNotContainSequence(sideEffectObserver1.values()) + } + } + } + + Feature("Container - Lifecycle") { + Scenario("Lifecycle action sent on container creation") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var testObserver: TestObserver + + Given("A middleware with no flows") { + middleware = createTestMiddleware { + perform("check lifecycle action") + .on() + .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("I connect to the middleware") { + testObserver = orbitContainer.orbit.test() + } + + Then("I get the modified state") { + testObserver.assertValueSequence(listOf(TestState(43))) + } + } + } +}) diff --git a/orbit/src/test/java/com/babylon/orbit/OrbitContainerThreadingSpek.kt b/orbit/src/test/java/com/babylon/orbit/OrbitContainerThreadingSpek.kt new file mode 100644 index 00000000..efdbc0c3 --- /dev/null +++ b/orbit/src/test/java/com/babylon/orbit/OrbitContainerThreadingSpek.kt @@ -0,0 +1,284 @@ +/* + * Copyright 2019 Babylon Partners Limited + * + * 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 + * + * http://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. + */ + +package com.babylon.orbit + +import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.subjects.PublishSubject +import org.assertj.core.api.Assertions.assertThat +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.gherkin.Feature + +internal class OrbitContainerThreadingSpek : Spek({ + + beforeGroup { + RxJavaPlugins.setIoSchedulerHandler { + RxJavaPlugins.createIoScheduler { + Thread( + it, + "IO" + ) + } + } + } + + afterGroup { + RxJavaPlugins.reset() + } + + Feature("Container - Threading") { + + Scenario("Side effects execute on the current thread (before a transform - reducer thread)") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + val testSubject = PublishSubject.create() + val testObserver = testSubject.test() + lateinit var sideEffectThreadName: String + + Given("A middleware with a side effect") { + middleware = createTestMiddleware { + perform("something") + .on() + .sideEffect { + sideEffectThreadName = Thread.currentThread().name + testSubject.onNext(event) + } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("sending an action") { + orbitContainer.sendAction(5) + testObserver.awaitCount(1) + } + + Then("The side effect runs on the reducer thread") { + assertThat(sideEffectThreadName).isEqualTo("reducerThread") + } + + } + + Scenario("Reducers execute on reducer thread") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + val testSubject = PublishSubject.create() + val testObserver = testSubject.test() + lateinit var reducerThreadName: String + + Given("A middleware with a reducer") { + middleware = createTestMiddleware { + perform("something") + .on() + .withReducer { + reducerThreadName = Thread.currentThread().name + testSubject.onNext(event) + getCurrentState() + } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("sending an action") { + orbitContainer.sendAction(5) + testObserver.awaitCount(1) + } + + Then("The reducer runs on the reducer thread") { + assertThat(reducerThreadName).isEqualTo("reducerThread") + } + } + + Scenario("Transformer executes on IO thread") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + val testSubject = PublishSubject.create() + val testObserver = testSubject.test() + lateinit var transformThreadName: String + + Given("A middleware with a transformer") { + middleware = createTestMiddleware { + perform("something") + .on() + .transform { + eventObservable.map { it * 2 } + .doOnNext { + transformThreadName = Thread.currentThread().name + testSubject.onNext(it) + } + } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("sending an action") { + orbitContainer.sendAction(5) + testObserver.awaitCount(1) + } + + Then("The transformer runs on the IO thread") { + assertThat(transformThreadName).isEqualTo("IO") + } + } + + Scenario("The downstream side effects of a transformer execute on IO thread") { + + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + val testSubject = PublishSubject.create() + val testObserver = testSubject.test() + lateinit var firstSideEffectThreadName: String + lateinit var firstransformThreadName: String + lateinit var secondSideEffectThreadName: String + + Given("A middleware with a mix of side effects and transformers") { + middleware = createTestMiddleware { + perform("something") + .on() + .sideEffect { + firstSideEffectThreadName = Thread.currentThread().name + testSubject.onNext(event) + } + .transform { + eventObservable.map { it * 2 } + .doOnNext { + firstransformThreadName = Thread.currentThread().name + testSubject.onNext(it) + } + } + .sideEffect { + secondSideEffectThreadName = Thread.currentThread().name + testSubject.onNext(event) + } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("sending an action") { + orbitContainer.sendAction(5) + testObserver.awaitCount(3) + } + + Then("The first side effect runs on the reducer thread") { + assertThat(firstSideEffectThreadName).isEqualTo("reducerThread") + } + + And("The first transformer runs on the IO thread") { + assertThat(firstransformThreadName).isEqualTo("IO") + } + + And("The second side effect runs on the IO thread") { + assertThat(secondSideEffectThreadName).isEqualTo("IO") + } + } + + Scenario("The downstream transformers of a transformer execute on IO thread") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + val testSubject = PublishSubject.create() + val testObserver = testSubject.test() + lateinit var firstransformThreadName: String + lateinit var secondTransformThreadName: String + + Given("A middleware with two transformers") { + middleware = createTestMiddleware { + perform("something") + .on() + .transform { + eventObservable.map { it * 2 } + .doOnNext { + firstransformThreadName = Thread.currentThread().name + testSubject.onNext(it) + } + } + .transform { + eventObservable.map { it * 2 } + .doOnNext { + secondTransformThreadName = Thread.currentThread().name + testSubject.onNext(it) + } + } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("sending an action") { + orbitContainer.sendAction(5) + testObserver.awaitCount(2) + } + + Then("The first transformer runs on the IO thread") { + assertThat(firstransformThreadName).isEqualTo("IO") + } + + And("The second transformer runs on the IO thread") { + assertThat(secondTransformThreadName).isEqualTo("IO") + } + } + + Scenario("The downstream reducers of a transformer execute on reducer thread") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + val testSubject = PublishSubject.create() + val testObserver = testSubject.test() + lateinit var firstReducerThreadName: String + lateinit var firstransformThreadName: String + lateinit var secondReducerThreadName: String + + Given("A middleware with a mix of reducers and transformers") { + middleware = createTestMiddleware { + perform("something") + .on() + .withReducer { + firstReducerThreadName = Thread.currentThread().name + testSubject.onNext(event) + getCurrentState() + } + .transform { + eventObservable.map { it * 2 } + .doOnNext { + firstransformThreadName = Thread.currentThread().name + testSubject.onNext(it) + } + } + .withReducer { + secondReducerThreadName = Thread.currentThread().name + testSubject.onNext(event) + getCurrentState() + } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("sending an action") { + orbitContainer.sendAction(5) + testObserver.awaitCount(3) + } + + + Then("The first reducer runs on the reducer thread") { + assertThat(firstReducerThreadName).isEqualTo("reducerThread") + } + + And("The first transformer runs on the IO thread") { + assertThat(firstransformThreadName).isEqualTo("IO") + } + + And("The second reducer runs on the reducer thread") { + assertThat(secondReducerThreadName).isEqualTo("reducerThread") + } + } + } +}) diff --git a/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt b/orbit/src/test/java/com/babylon/orbit/OrbitDSLSpek.kt similarity index 87% rename from orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt rename to orbit/src/test/java/com/babylon/orbit/OrbitDSLSpek.kt index dcb6c788..00663491 100644 --- a/orbit/src/test/java/com/babylon/orbit/OrbitSpek.kt +++ b/orbit/src/test/java/com/babylon/orbit/OrbitDSLSpek.kt @@ -17,6 +17,7 @@ package com.babylon.orbit import io.reactivex.observers.TestObserver +import io.reactivex.plugins.RxJavaPlugins import io.reactivex.subjects.PublishSubject import org.assertj.core.api.Assertions import org.assertj.core.api.Assertions.assertThat @@ -24,7 +25,7 @@ import org.spekframework.spek2.Spek import org.spekframework.spek2.style.gherkin.Feature import java.util.concurrent.CountDownLatch -internal class OrbitSpek : Spek({ +internal class OrbitDSLSpek : Spek({ Feature("DSL - syntax") { createTestMiddleware { @@ -365,49 +366,4 @@ internal class OrbitSpek : Spek({ } } } - - Feature("Container - State") { - - Scenario("Initial state is always emitted") { - lateinit var middleware: Middleware - lateinit var orbitContainer: BaseOrbitContainer - lateinit var testObserver: TestObserver - - Given("A middleware with no flows") { - middleware = createTestMiddleware {} - orbitContainer = BaseOrbitContainer(middleware) - } - - When("connecting to the middleware") { - testObserver = orbitContainer.orbit.test() - } - - Then("emits the initial state") { - testObserver.assertValueSequence(listOf(middleware.initialState)) - } - } - - Scenario("Current state always emitted upon subscription") {} - Scenario("Updated state is emitted after it changes while nothing is connected") {} - Scenario("Current state can be queried directly after modification") {} - } - - Feature("Container - Side Effects") { - Scenario("Side effects are multicast to all current observers") {} - Scenario("Side effects are cached while there is no connected observer") {} // Is this the responsibility of this library? - Scenario("Cached side effects are guaranteed to be delivered to the first observer") {} - Scenario("Cached side effects are not guaranteed to be delivered to observers beyond the first") {} - } - - Feature("Container - Threading") { - Scenario("Side effects execute on the current thread (before a tranform - reducer thread)") {} - Scenario("Reducers execute on reducer thread") {} - Scenario("Transformer executes on IO thread") {} - Scenario("The downstream transformers and side effects of a transformer execute on IO thread") {} - Scenario("The downstream reducers of a transformer executes on reducer thread") {} - } - - Feature("Container - Lifecycle") { - Scenario("Lifecycle action sent on container creation") {} - } }) From badf457feb4e67a14932bd58d9c46844aa8cf425 Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Sun, 10 Nov 2019 16:37:47 +0100 Subject: [PATCH 11/19] #25 Added more unit tests --- .../src/main/kotlin/DependencyManagement.kt | 7 +- orbit-android/orbit-android_build.gradle.kts | 2 + .../java/com/babylon/orbit/OrbitViewModel.kt | 21 +- .../orbit/AndroidOrbitContainerSpek.kt | 2 +- .../orbit/OrbitViewModelLifecycleTest.kt | 241 ++++++++++++++++++ .../com/babylon/orbit/OrbitViewModelSpek.kt | 23 -- .../java/com/babylon/orbit/OrbitContainer.kt | 4 +- 7 files changed, 257 insertions(+), 43 deletions(-) create mode 100644 orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt delete mode 100644 orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelSpek.kt diff --git a/buildSrc/src/main/kotlin/DependencyManagement.kt b/buildSrc/src/main/kotlin/DependencyManagement.kt index 68078507..11cf0c50 100644 --- a/buildSrc/src/main/kotlin/DependencyManagement.kt +++ b/buildSrc/src/main/kotlin/DependencyManagement.kt @@ -47,6 +47,8 @@ object Versions { const val junitPlatform = "1.5.2" const val assertJ = "3.13.2" const val mockitoKotlin = "2.1.0" + const val robolectric = "4.3" + const val junit4 = "4.12" } object ProjectDependencies { @@ -68,7 +70,6 @@ object ProjectDependencies { // Reactive extension related stuff const val rxJava2 = "io.reactivex.rxjava2:rxjava:${Versions.rxJava2}" const val rxJava2Extensions = "com.github.akarnokd:rxjava2-extensions:${Versions.rxJava2Extensions}" - const val rxRelay = "com.jakewharton.rxrelay2:rxrelay:${Versions.rxRelay}" const val rxKotlin = "io.reactivex.rxjava2:rxkotlin:${Versions.rxKotlin}" const val rxAndroid = "io.reactivex.rxjava2:rxandroid:${Versions.rxAndroid}" const val autodispose = "com.uber.autodispose:autodispose:${Versions.autodispose}" @@ -86,6 +87,10 @@ object ProjectDependencies { const val junitPlatformConsole = "org.junit.platform:junit-platform-console:${Versions.junitPlatform}" const val assertJ = "org.assertj:assertj-core:${Versions.assertJ}" const val mockitoKotlin = "com.nhaarman.mockitokotlin2:mockito-kotlin:${Versions.mockitoKotlin}" + + // Testing + const val robolectric = "org.robolectric:robolectric:${Versions.robolectric}" + const val junit4 = "junit:junit:${Versions.junit4}" } object PluginDependencies { diff --git a/orbit-android/orbit-android_build.gradle.kts b/orbit-android/orbit-android_build.gradle.kts index 404ac520..1f260559 100644 --- a/orbit-android/orbit-android_build.gradle.kts +++ b/orbit-android/orbit-android_build.gradle.kts @@ -68,4 +68,6 @@ dependencies { // Testing GroupedDependencies.spekTestsImplementation.forEach { testImplementation(it) } GroupedDependencies.spekTestsRuntime.forEach { testRuntimeOnly(it) } + testImplementation(ProjectDependencies.robolectric) + testImplementation(ProjectDependencies.junit4) } diff --git a/orbit-android/src/main/java/com/babylon/orbit/OrbitViewModel.kt b/orbit-android/src/main/java/com/babylon/orbit/OrbitViewModel.kt index 08efcef7..121f57a2 100644 --- a/orbit-android/src/main/java/com/babylon/orbit/OrbitViewModel.kt +++ b/orbit-android/src/main/java/com/babylon/orbit/OrbitViewModel.kt @@ -20,20 +20,15 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModel import com.uber.autodispose.android.lifecycle.autoDispose -abstract class OrbitViewModel( +open class OrbitViewModel( middleware: Middleware -) : ViewModel() { +) : ViewModel(), OrbitContainer by AndroidOrbitContainer(middleware) { constructor( initialState: STATE, init: OrbitsBuilder.() -> Unit ) : this(middleware(initialState, init)) - private val container: AndroidOrbitContainer = AndroidOrbitContainer(middleware) - - val currentState: STATE - get() = container.currentState - /** * Designed to be called in onStart or onResume, depending on your use case. * DO NOT call in other lifecycle methods unless you know what you're doing! @@ -46,20 +41,14 @@ abstract class OrbitViewModel( sideEffectConsumer: (SIDE_EFFECT) -> Unit = {} ) { - container.orbit - .autoDispose(lifecycleOwner) + orbit.autoDispose(lifecycleOwner) .subscribe(stateConsumer) - container.sideEffect - .autoDispose(lifecycleOwner) + sideEffect.autoDispose(lifecycleOwner) .subscribe(sideEffectConsumer) } - fun sendAction(action: Any) { - container.sendAction(action) - } - override fun onCleared() { - container.disposeOrbit() + disposeOrbit() } } diff --git a/orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt b/orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt index 64cff9b7..a67be66a 100644 --- a/orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt +++ b/orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt @@ -29,7 +29,7 @@ class AndroidOrbitContainerSpek : Spek({ lateinit var stateObserver: TestObserver lateinit var sideEffectObserver: TestObserver - Given("A middleware with no flows") { + Given("An android container with a simple middleware") { middleware = createTestMiddleware { perform("send side effect") .on() diff --git a/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt new file mode 100644 index 00000000..7f2288ea --- /dev/null +++ b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt @@ -0,0 +1,241 @@ +package com.babylon.orbit + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.nhaarman.mockitokotlin2.mock +import io.reactivex.android.plugins.RxAndroidPlugins +import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.subjects.PublishSubject +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class OrbitViewModelLifecycleTest { + + @Before + fun before() { + RxAndroidPlugins.setInitMainThreadSchedulerHandler { + RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } + } + RxAndroidPlugins.setMainThreadSchedulerHandler { + RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } + } + } + + @After + fun after() { + RxJavaPlugins.reset() + } + + @Test + fun `If I connect in onCreate I get disconnected in onDestroy`() { + RxAndroidPlugins.setInitMainThreadSchedulerHandler { + RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } + } + RxAndroidPlugins.setMainThreadSchedulerHandler { + RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } + } + lateinit var orbitViewModel: OrbitViewModel + val lifecycle = LifecycleRegistry(mock()) + val lifecycleOwner = LifecycleOwner { lifecycle } + val stateSubject = PublishSubject.create() + val stateObserver = stateSubject.test() + val sideEffectSubject = PublishSubject.create() + val sideEffectObserver = sideEffectSubject.test() + + // Given A middleware with no flows + val middleware = createTestMiddleware { + perform("send side effect") + .on() + .sideEffect { post("foobar") } + .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + } + orbitViewModel = OrbitViewModel(middleware) + + // When I connect to the view model in onCreate + orbitViewModel.connect(lifecycleOwner, + stateConsumer = { stateSubject.onNext(it) }, + sideEffectConsumer = { sideEffectSubject.onNext(it) } + ) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + stateObserver.awaitCount(1) + + // Then I receive the initial state + assertThat(stateObserver.values()).containsExactly(middleware.initialState) + + // When I transition through to stopped + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + + // And I send an action to the container + orbitViewModel.sendAction(Unit) + stateObserver.awaitCount(2) + sideEffectObserver.awaitCount(1) + + // Then I receive the updated state + assertThat(stateObserver.values()) + .containsExactly(middleware.initialState, TestState(43)) + + // And I receive the side effect + assertThat(sideEffectObserver.values()) + .containsExactly("foobar") + + // When I transition through to destroyed + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + + // And I send an action to the container + orbitViewModel.sendAction(Unit) + stateObserver.awaitCount(3) + + // Then I do not receive further updates + assertThat(stateObserver.values()) + .containsExactly(middleware.initialState, TestState(43)) + + // And I do not receive further side effects + assertThat(sideEffectObserver.values()) + .containsExactly("foobar") + } + + @Test + fun `If I connect in onStart I get disconnected in onStop`() { + RxAndroidPlugins.setInitMainThreadSchedulerHandler { + RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } + } + RxAndroidPlugins.setMainThreadSchedulerHandler { + RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } + } + lateinit var orbitViewModel: OrbitViewModel + val lifecycle = LifecycleRegistry(mock()) + val lifecycleOwner = LifecycleOwner { lifecycle } + val stateSubject = PublishSubject.create() + val stateObserver = stateSubject.test() + val sideEffectSubject = PublishSubject.create() + val sideEffectObserver = sideEffectSubject.test() + + // Given A middleware with no flows + val middleware = createTestMiddleware { + perform("send side effect") + .on() + .sideEffect { post("foobar") } + .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + } + orbitViewModel = OrbitViewModel(middleware) + + // When I connect to the view model in onStart + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + orbitViewModel.connect(lifecycleOwner, + stateConsumer = { stateSubject.onNext(it) }, + sideEffectConsumer = { sideEffectSubject.onNext(it) } + ) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START) + stateObserver.awaitCount(1) + + // Then I receive the initial state + assertThat(stateObserver.values()).containsExactly(middleware.initialState) + + // When I transition through to paused + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + + // And I send an action to the container + orbitViewModel.sendAction(Unit) + stateObserver.awaitCount(2) + + // Then I receive the updated state + assertThat(stateObserver.values()) + .containsExactly(middleware.initialState, TestState(43)) + + // And I receive the side effect + assertThat(sideEffectObserver.values()) + .containsExactly("foobar") + + // When I transition through to stopped + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + + // And I send an action to the container + orbitViewModel.sendAction(Unit) + stateObserver.awaitCount(3) + + // Then I do not receive further updates + assertThat(stateObserver.values()) + .containsExactly(middleware.initialState, TestState(43)) + + // And I do not receive further side effects + assertThat(sideEffectObserver.values()) + .containsExactly("foobar") + } + + @Test + fun `If I connect in onResume I get disconnected in onPause`() { + RxAndroidPlugins.setInitMainThreadSchedulerHandler { + RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } + } + RxAndroidPlugins.setMainThreadSchedulerHandler { + RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } + } + lateinit var orbitViewModel: OrbitViewModel + val lifecycle = LifecycleRegistry(mock()) + val lifecycleOwner = LifecycleOwner { lifecycle } + val stateSubject = PublishSubject.create() + val stateObserver = stateSubject.test() + val sideEffectSubject = PublishSubject.create() + val sideEffectObserver = sideEffectSubject.test() + + // Given A middleware with no flows + val middleware = createTestMiddleware { + perform("send side effect") + .on() + .sideEffect { post("foobar") } + .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + } + orbitViewModel = OrbitViewModel(middleware) + + // When I connect to the view model in onResume + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START) + orbitViewModel.connect(lifecycleOwner, + stateConsumer = { stateSubject.onNext(it) }, + sideEffectConsumer = { sideEffectSubject.onNext(it) } + ) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + stateObserver.awaitCount(1) + + // When I send an action to the container + orbitViewModel.sendAction(Unit) + stateObserver.awaitCount(2) + + // Then I receive the updated state + assertThat(stateObserver.values()) + .containsExactly(middleware.initialState, TestState(43)) + + // And I receive the side effect + assertThat(sideEffectObserver.values()) + .containsExactly("foobar") + + // When I transition through to paused + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + + // And I send an action to the container + orbitViewModel.sendAction(Unit) + stateObserver.awaitCount(2) + + // Then I do not receive further updates + assertThat(stateObserver.values()) + .containsExactly(middleware.initialState, TestState(43)) + + // And I do not receive further side effects + assertThat(sideEffectObserver.values()) + .containsExactly("foobar") + } + + @Test + fun `Instance of view is not retained after disconnection`() { + // This would be a very useful test but not sure how to write this + } +} diff --git a/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelSpek.kt b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelSpek.kt deleted file mode 100644 index 3b89ed96..00000000 --- a/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelSpek.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.babylon.orbit - -import org.spekframework.spek2.Spek -import org.spekframework.spek2.style.gherkin.Feature - -class OrbitViewModelSpek : Spek({ - Feature("View Model - State") { - Scenario("The current state can be queried") {} - } - Feature("View Model - Lifecycle") { - Scenario("If I connect in onCreate I get disconnected in onDestroy") {} - Scenario("If I connect in onStart I get disconnected in onStop") {} - Scenario("If I connect in onResume I get disconnected in onPause") {} - Scenario("If I connect in methods other than onCreate/onStart/onResume I get an exception") {} - // TODO think if above makes sense in context of fragment lifecycle - } - Feature("View Model - Connection") { - Scenario("Actions are delivered to the orbit container even if view is not connected") {} - Scenario("I receive state updates and side effects when connected") {} - Scenario("I do not receive state updates and side effects when disconnected") {} - Scenario("Instance of view is not retained after disconnection") {} // How to test this - } -}) \ No newline at end of file diff --git a/orbit/src/main/java/com/babylon/orbit/OrbitContainer.kt b/orbit/src/main/java/com/babylon/orbit/OrbitContainer.kt index 89a61aa2..482f373d 100644 --- a/orbit/src/main/java/com/babylon/orbit/OrbitContainer.kt +++ b/orbit/src/main/java/com/babylon/orbit/OrbitContainer.kt @@ -18,10 +18,10 @@ package com.babylon.orbit import io.reactivex.Observable -interface OrbitContainer { +interface OrbitContainer { val currentState: STATE val orbit: Observable - val sideEffect: Observable + val sideEffect: Observable fun sendAction(action: Any) fun disposeOrbit() } From c009e7c1737c446c03472fcfc21a4388b7d3ef5e Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Sun, 10 Nov 2019 16:51:13 +0100 Subject: [PATCH 12/19] Fixed running vintage tests from command line --- buildSrc/src/main/kotlin/DependencyManagement.kt | 2 ++ orbit-android/orbit-android_build.gradle.kts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/buildSrc/src/main/kotlin/DependencyManagement.kt b/buildSrc/src/main/kotlin/DependencyManagement.kt index 11cf0c50..2939fd96 100644 --- a/buildSrc/src/main/kotlin/DependencyManagement.kt +++ b/buildSrc/src/main/kotlin/DependencyManagement.kt @@ -49,6 +49,7 @@ object Versions { const val mockitoKotlin = "2.1.0" const val robolectric = "4.3" const val junit4 = "4.12" + const val junitVintage = "5.5.2" } object ProjectDependencies { @@ -91,6 +92,7 @@ object ProjectDependencies { // Testing const val robolectric = "org.robolectric:robolectric:${Versions.robolectric}" const val junit4 = "junit:junit:${Versions.junit4}" + const val junitVintage = "org.junit.vintage:junit-vintage-engine:${Versions.junitVintage}" } object PluginDependencies { diff --git a/orbit-android/orbit-android_build.gradle.kts b/orbit-android/orbit-android_build.gradle.kts index 1f260559..64a5d32e 100644 --- a/orbit-android/orbit-android_build.gradle.kts +++ b/orbit-android/orbit-android_build.gradle.kts @@ -50,6 +50,12 @@ tasks.withType(KotlinCompile::class.java).all { jvmTarget = "1.8" } } +tasks.withType(Test::class.java) { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + } +} dependencies { implementation(project(":orbit")) @@ -70,4 +76,5 @@ dependencies { GroupedDependencies.spekTestsRuntime.forEach { testRuntimeOnly(it) } testImplementation(ProjectDependencies.robolectric) testImplementation(ProjectDependencies.junit4) + testRuntimeOnly(ProjectDependencies.junitVintage) } From 053471acb0d8cf24f2f37fc2392b106fb0e988c6 Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Mon, 11 Nov 2019 11:00:11 +0100 Subject: [PATCH 13/19] Added test for view leakage --- .../orbit/OrbitViewModelLifecycleTest.kt | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt index 7f2288ea..06c84c08 100644 --- a/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt +++ b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt @@ -13,6 +13,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.lang.ref.WeakReference @RunWith(RobolectricTestRunner::class) class OrbitViewModelLifecycleTest { @@ -236,6 +237,69 @@ class OrbitViewModelLifecycleTest { @Test fun `Instance of view is not retained after disconnection`() { - // This would be a very useful test but not sure how to write this + RxAndroidPlugins.setInitMainThreadSchedulerHandler { + RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } + } + RxAndroidPlugins.setMainThreadSchedulerHandler { + RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } + } + lateinit var orbitViewModel: OrbitViewModel + lateinit var weakConsumer: WeakReference + val lifecycle = LifecycleRegistry(mock()) + val lifecycleOwner = LifecycleOwner { lifecycle } + val stateSubject = PublishSubject.create() + val stateObserver = stateSubject.test() + val sideEffectSubject = PublishSubject.create() + + // Given A middleware with no flows + val middleware = createTestMiddleware { + perform("send side effect") + .on() + .sideEffect { post("foobar") } + .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + } + orbitViewModel = OrbitViewModel(middleware) + + // When I connect to the view model in onStart + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + weakConsumer = WeakReference(Consumer(stateSubject, sideEffectSubject)).also { + orbitViewModel.connect( + lifecycleOwner, + it.get()!!::consumeState, + it.get()!!::consumeSideEffect + ) + } + + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START) + stateObserver.awaitCount(1) + + // Then I receive the initial state + assertThat(stateObserver.values()).containsExactly(middleware.initialState) + + // When I transition through to stopped + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + + // Then I expect the consumer to be cleared + var cleared = false + for (i in 0..15) { + System.gc() + Runtime.getRuntime().gc() + if (!cleared) { + cleared = weakConsumer.get() == null + Thread.sleep(1000) + } else break + } + assertThat(cleared).isTrue() } } + +internal class Consumer( + private val stateSubject: PublishSubject, + private val sideEffectSubject: PublishSubject +) { + fun consumeState(state: TestState) = stateSubject.onNext(state) + fun consumeSideEffect(sideEffect: String) = sideEffectSubject.onNext(sideEffect) + +} \ No newline at end of file From cd7402e71b144acc0659a1176869462132c02c97 Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Mon, 11 Nov 2019 12:48:58 +0100 Subject: [PATCH 14/19] Fixed running vintage tests from command line --- .../babylon/orbit/AndroidOrbitContainer.kt | 32 -------- .../java/com/babylon/orbit/OrbitViewModel.kt | 26 +++++-- .../orbit/AndroidOrbitContainerSpek.kt | 59 --------------- .../orbit/OrbitViewModelLifecycleTest.kt | 24 ------ .../orbit/OrbitViewModelThreadingTest.kt | 74 +++++++++++++++++++ .../com/babylon/orbit/OrbitContainerSpek.kt | 2 +- 6 files changed, 94 insertions(+), 123 deletions(-) delete mode 100644 orbit-android/src/main/java/com/babylon/orbit/AndroidOrbitContainer.kt delete mode 100644 orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt create mode 100644 orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelThreadingTest.kt diff --git a/orbit-android/src/main/java/com/babylon/orbit/AndroidOrbitContainer.kt b/orbit-android/src/main/java/com/babylon/orbit/AndroidOrbitContainer.kt deleted file mode 100644 index ee8075a1..00000000 --- a/orbit-android/src/main/java/com/babylon/orbit/AndroidOrbitContainer.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2019 Babylon Partners Limited - * - * 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 - * - * http://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. - */ - -package com.babylon.orbit - -import io.reactivex.Observable -import io.reactivex.android.schedulers.AndroidSchedulers - -class AndroidOrbitContainer private constructor( - private val delegate: BaseOrbitContainer -) : OrbitContainer by delegate { - - constructor(middleware: Middleware) : this(BaseOrbitContainer(middleware)) - - override val orbit: Observable = delegate.orbit.observeOn(AndroidSchedulers.mainThread()) - - override val sideEffect: Observable = - delegate.sideEffect.observeOn(AndroidSchedulers.mainThread()) -} diff --git a/orbit-android/src/main/java/com/babylon/orbit/OrbitViewModel.kt b/orbit-android/src/main/java/com/babylon/orbit/OrbitViewModel.kt index 121f57a2..184bd7db 100644 --- a/orbit-android/src/main/java/com/babylon/orbit/OrbitViewModel.kt +++ b/orbit-android/src/main/java/com/babylon/orbit/OrbitViewModel.kt @@ -19,15 +19,26 @@ package com.babylon.orbit import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModel import com.uber.autodispose.android.lifecycle.autoDispose +import io.reactivex.android.schedulers.AndroidSchedulers open class OrbitViewModel( - middleware: Middleware -) : ViewModel(), OrbitContainer by AndroidOrbitContainer(middleware) { + private val container: OrbitContainer +) : ViewModel() { constructor( initialState: STATE, init: OrbitsBuilder.() -> Unit - ) : this(middleware(initialState, init)) + ) : this(BaseOrbitContainer(middleware(initialState, init))) + + constructor(middleware: Middleware) : this(BaseOrbitContainer(middleware)) + + val currentState: STATE + get() = container.currentState + + fun sendAction(action: Any) = container.sendAction(action) + + private val mainThreadOrbit = container.orbit.observeOn(AndroidSchedulers.mainThread()) + private val mainThreadSideEffect = container.sideEffect.observeOn(AndroidSchedulers.mainThread()) /** * Designed to be called in onStart or onResume, depending on your use case. @@ -40,15 +51,16 @@ open class OrbitViewModel( stateConsumer: (STATE) -> Unit, sideEffectConsumer: (SIDE_EFFECT) -> Unit = {} ) { - - orbit.autoDispose(lifecycleOwner) + mainThreadOrbit + .autoDispose(lifecycleOwner) .subscribe(stateConsumer) - sideEffect.autoDispose(lifecycleOwner) + mainThreadSideEffect + .autoDispose(lifecycleOwner) .subscribe(sideEffectConsumer) } override fun onCleared() { - disposeOrbit() + container.disposeOrbit() } } diff --git a/orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt b/orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt deleted file mode 100644 index a67be66a..00000000 --- a/orbit-android/src/test/java/com/babylon/orbit/AndroidOrbitContainerSpek.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.babylon.orbit - -import io.reactivex.android.plugins.RxAndroidPlugins -import io.reactivex.observers.TestObserver -import io.reactivex.plugins.RxJavaPlugins -import org.assertj.core.api.Assertions.assertThat -import org.spekframework.spek2.Spek -import org.spekframework.spek2.style.gherkin.Feature - -class AndroidOrbitContainerSpek : Spek({ - - beforeGroup { - RxAndroidPlugins.setInitMainThreadSchedulerHandler { - RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } - } - RxAndroidPlugins.setMainThreadSchedulerHandler { - RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } - } - } - - afterGroup { - RxJavaPlugins.reset() - } - - Feature("Android Container - Threading") { - Scenario("Side effects and state updates are received on the android main thread") { - lateinit var middleware: Middleware - lateinit var orbitContainer: AndroidOrbitContainer - lateinit var stateObserver: TestObserver - lateinit var sideEffectObserver: TestObserver - - Given("An android container with a simple middleware") { - middleware = createTestMiddleware { - perform("send side effect") - .on() - .sideEffect { post("foo") } - .sideEffect { post("bar") } - .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } - } - orbitContainer = AndroidOrbitContainer(middleware) - } - - When("I send an event to the container") { - stateObserver = orbitContainer.orbit.test() - sideEffectObserver = orbitContainer.sideEffect.test() - orbitContainer.sendAction(Unit) - stateObserver.awaitCount(2) - } - - Then("The state observer listens on the android main thread") { - assertThat(stateObserver.lastThread().name).isEqualTo("main") - } - - And("The side effect observer listens on the android main thread") { - assertThat(sideEffectObserver.lastThread().name).isEqualTo("main") - } - } - } -}) \ No newline at end of file diff --git a/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt index 06c84c08..3e27e15c 100644 --- a/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt +++ b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt @@ -35,12 +35,6 @@ class OrbitViewModelLifecycleTest { @Test fun `If I connect in onCreate I get disconnected in onDestroy`() { - RxAndroidPlugins.setInitMainThreadSchedulerHandler { - RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } - } - RxAndroidPlugins.setMainThreadSchedulerHandler { - RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } - } lateinit var orbitViewModel: OrbitViewModel val lifecycle = LifecycleRegistry(mock()) val lifecycleOwner = LifecycleOwner { lifecycle } @@ -105,12 +99,6 @@ class OrbitViewModelLifecycleTest { @Test fun `If I connect in onStart I get disconnected in onStop`() { - RxAndroidPlugins.setInitMainThreadSchedulerHandler { - RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } - } - RxAndroidPlugins.setMainThreadSchedulerHandler { - RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } - } lateinit var orbitViewModel: OrbitViewModel val lifecycle = LifecycleRegistry(mock()) val lifecycleOwner = LifecycleOwner { lifecycle } @@ -174,12 +162,6 @@ class OrbitViewModelLifecycleTest { @Test fun `If I connect in onResume I get disconnected in onPause`() { - RxAndroidPlugins.setInitMainThreadSchedulerHandler { - RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } - } - RxAndroidPlugins.setMainThreadSchedulerHandler { - RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } - } lateinit var orbitViewModel: OrbitViewModel val lifecycle = LifecycleRegistry(mock()) val lifecycleOwner = LifecycleOwner { lifecycle } @@ -237,12 +219,6 @@ class OrbitViewModelLifecycleTest { @Test fun `Instance of view is not retained after disconnection`() { - RxAndroidPlugins.setInitMainThreadSchedulerHandler { - RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } - } - RxAndroidPlugins.setMainThreadSchedulerHandler { - RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } - } lateinit var orbitViewModel: OrbitViewModel lateinit var weakConsumer: WeakReference val lifecycle = LifecycleRegistry(mock()) diff --git a/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelThreadingTest.kt b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelThreadingTest.kt new file mode 100644 index 00000000..814c4119 --- /dev/null +++ b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelThreadingTest.kt @@ -0,0 +1,74 @@ +package com.babylon.orbit + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.nhaarman.mockitokotlin2.mock +import io.reactivex.android.plugins.RxAndroidPlugins +import io.reactivex.observers.TestObserver +import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.subjects.PublishSubject +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class OrbitViewModelThreadingTest { + + @Before + fun before() { + RxAndroidPlugins.setInitMainThreadSchedulerHandler { + RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } + } + RxAndroidPlugins.setMainThreadSchedulerHandler { + RxJavaPlugins.createNewThreadScheduler { Thread(it, "main") } + } + } + + @After + fun after() { + RxJavaPlugins.reset() + } + + @Test + fun `Side effects and state updates are received on the android main thread`() { + val lifecycle = LifecycleRegistry(mock()) + val lifecycleOwner = LifecycleOwner { lifecycle } + val stateSubject = PublishSubject.create() + val stateObserver = stateSubject.test() + val sideEffectSubject = PublishSubject.create() + val sideEffectObserver = sideEffectSubject.test() + + // Given an android view model with a simple middleware + val middleware = createTestMiddleware { + perform("send side effect") + .on() + .sideEffect { post("foo") } + .sideEffect { post("bar") } + .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + } + val orbitViewModel = OrbitViewModel(middleware) + + // When I connect to the viewModel in onCreate + orbitViewModel.connect(lifecycleOwner, + stateConsumer = { stateSubject.onNext(it) }, + sideEffectConsumer = { sideEffectSubject.onNext(it) } + ) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + stateObserver.awaitCount(1) + + // When I send an event to the container + orbitViewModel.sendAction(Unit) + stateObserver.awaitCount(2) + + // Then the state observer listens on the android main thread + assertThat(stateObserver.lastThread().name).isEqualTo("main") + + // And The side effect observer listens on the android main thread + assertThat(sideEffectObserver.lastThread().name).isEqualTo("main") + } + +} \ No newline at end of file diff --git a/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt b/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt index c3533e73..d0cb88cb 100644 --- a/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt +++ b/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt @@ -193,7 +193,7 @@ internal class OrbitContainerSpek : Spek({ } // Is this the responsibility of this library? - Scenario("Side effects are cached while there is no connected observer ") { + Scenario("Side effects are cached while there is no connected observer") { lateinit var middleware: Middleware lateinit var orbitContainer: BaseOrbitContainer lateinit var stateObserver: TestObserver From de4ea2092ff33622204a59e8ecdcf874834f8a3f Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Mon, 11 Nov 2019 16:01:35 +0100 Subject: [PATCH 15/19] Added configurable side effect caching with multicasting --- .../orbit/OrbitViewModelLifecycleTest.kt | 4 +- .../orbit/OrbitViewModelThreadingTest.kt | 4 +- .../test/java/com/babylon/orbit/TestState.kt | 3 + .../test/java/com/babylon/orbit/TestUtil.kt | 2 - .../com/babylon/orbit/BaseOrbitContainer.kt | 11 +- orbit/src/main/java/com/babylon/orbit/Dsl.kt | 17 ++ .../main/java/com/babylon/orbit/Middleware.kt | 5 + .../orbit/{OrbitDSLSpek.kt => DslSpek.kt} | 51 ++++- .../com/babylon/orbit/OrbitContainerSpek.kt | 214 +++++++++++++++++- .../orbit/OrbitContainerThreadingSpek.kt | 2 - .../test/java/com/babylon/orbit/TestState.kt | 3 + .../test/java/com/babylon/orbit/TestUtil.kt | 2 - 12 files changed, 298 insertions(+), 20 deletions(-) create mode 100644 orbit-android/src/test/java/com/babylon/orbit/TestState.kt rename orbit/src/test/java/com/babylon/orbit/{OrbitDSLSpek.kt => DslSpek.kt} (89%) create mode 100644 orbit/src/test/java/com/babylon/orbit/TestState.kt diff --git a/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt index 3e27e15c..1677f343 100644 --- a/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt +++ b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelLifecycleTest.kt @@ -259,6 +259,7 @@ class OrbitViewModelLifecycleTest { // Then I expect the consumer to be cleared var cleared = false + @Suppress("ExplicitGarbageCollectionCall") for (i in 0..15) { System.gc() Runtime.getRuntime().gc() @@ -277,5 +278,4 @@ internal class Consumer( ) { fun consumeState(state: TestState) = stateSubject.onNext(state) fun consumeSideEffect(sideEffect: String) = sideEffectSubject.onNext(sideEffect) - -} \ No newline at end of file +} diff --git a/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelThreadingTest.kt b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelThreadingTest.kt index 814c4119..0db6ffb0 100644 --- a/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelThreadingTest.kt +++ b/orbit-android/src/test/java/com/babylon/orbit/OrbitViewModelThreadingTest.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import com.nhaarman.mockitokotlin2.mock import io.reactivex.android.plugins.RxAndroidPlugins -import io.reactivex.observers.TestObserver import io.reactivex.plugins.RxJavaPlugins import io.reactivex.subjects.PublishSubject import org.assertj.core.api.Assertions.assertThat @@ -70,5 +69,4 @@ class OrbitViewModelThreadingTest { // And The side effect observer listens on the android main thread assertThat(sideEffectObserver.lastThread().name).isEqualTo("main") } - -} \ No newline at end of file +} diff --git a/orbit-android/src/test/java/com/babylon/orbit/TestState.kt b/orbit-android/src/test/java/com/babylon/orbit/TestState.kt new file mode 100644 index 00000000..476788fb --- /dev/null +++ b/orbit-android/src/test/java/com/babylon/orbit/TestState.kt @@ -0,0 +1,3 @@ +package com.babylon.orbit + +data class TestState(val id: Int) diff --git a/orbit-android/src/test/java/com/babylon/orbit/TestUtil.kt b/orbit-android/src/test/java/com/babylon/orbit/TestUtil.kt index 73ad648a..f9b390e9 100644 --- a/orbit-android/src/test/java/com/babylon/orbit/TestUtil.kt +++ b/orbit-android/src/test/java/com/babylon/orbit/TestUtil.kt @@ -6,5 +6,3 @@ fun createTestMiddleware( ) = middleware(initialState) { this.apply(block) } - -data class TestState(val id: Int) \ No newline at end of file diff --git a/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt b/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt index 33eefdf2..c089c522 100644 --- a/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt +++ b/orbit/src/main/java/com/babylon/orbit/BaseOrbitContainer.kt @@ -16,6 +16,7 @@ package com.babylon.orbit +import hu.akarnokd.rxjava2.subjects.UnicastWorkSubject import io.reactivex.Observable import io.reactivex.Scheduler import io.reactivex.disposables.CompositeDisposable @@ -38,7 +39,15 @@ class BaseOrbitContainer( override var currentState: STATE = middleware.initialState private set override val orbit: ConnectableObservable - override val sideEffect: Observable = sideEffectSubject.hide() + override val sideEffect: Observable = + if (middleware.configuration.sideEffectCachingEnabled) { + UnicastWorkSubject.create() + .also { sideEffectSubject.subscribe(it) } + .publish() + .refCount() + } else { + sideEffectSubject + } init { val scheduler = createSingleScheduler() diff --git a/orbit/src/main/java/com/babylon/orbit/Dsl.kt b/orbit/src/main/java/com/babylon/orbit/Dsl.kt index a86c5113..29966bee 100644 --- a/orbit/src/main/java/com/babylon/orbit/Dsl.kt +++ b/orbit/src/main/java/com/babylon/orbit/Dsl.kt @@ -28,12 +28,23 @@ fun middleware( @OrbitDsl class ActionFilter(val description: String) +@OrbitDsl +class ConfigReceiver( + var sideEffectCachingEnabled: Boolean = true +) + @OrbitDsl open class OrbitsBuilder(private val initialState: STATE) { private val orbits = mutableMapOf.() -> Observable<*>>() private val descriptions = mutableSetOf() + private val config = ConfigReceiver() + + fun configuration(block: ConfigReceiver.() -> Unit) { + config.apply { block() } + } + @Suppress("unused") // Used for the nice extension function highlight fun OrbitsBuilder.perform(description: String) = ActionFilter(description) .also { @@ -118,12 +129,16 @@ open class OrbitsBuilder(private val initialStat } fun build() = object : Middleware { + override val configuration = Middleware.Config( + sideEffectCachingEnabled = config.sideEffectCachingEnabled + ) override val initialState: STATE = this@OrbitsBuilder.initialState override val orbits: List> = this@OrbitsBuilder.orbits.values.toList() } } +@OrbitDsl class TransformerReceiver( private val stateProvider: () -> STATE, val eventObservable: Observable @@ -131,6 +146,7 @@ class TransformerReceiver( fun getCurrentState() = stateProvider() } +@OrbitDsl class EventReceiver( private val stateProvider: () -> STATE, val event: EVENT @@ -138,6 +154,7 @@ class EventReceiver( fun getCurrentState() = stateProvider() } +@OrbitDsl class SideEffectEventReceiver( private val stateProvider: () -> STATE, private val sideEffectRelay: Subject, diff --git a/orbit/src/main/java/com/babylon/orbit/Middleware.kt b/orbit/src/main/java/com/babylon/orbit/Middleware.kt index 3b036448..ec286b42 100644 --- a/orbit/src/main/java/com/babylon/orbit/Middleware.kt +++ b/orbit/src/main/java/com/babylon/orbit/Middleware.kt @@ -33,4 +33,9 @@ data class OrbitContext( interface Middleware { val initialState: STATE val orbits: List> + val configuration: Config + + data class Config( + val sideEffectCachingEnabled: Boolean = true + ) } diff --git a/orbit/src/test/java/com/babylon/orbit/OrbitDSLSpek.kt b/orbit/src/test/java/com/babylon/orbit/DslSpek.kt similarity index 89% rename from orbit/src/test/java/com/babylon/orbit/OrbitDSLSpek.kt rename to orbit/src/test/java/com/babylon/orbit/DslSpek.kt index 00663491..177cc38f 100644 --- a/orbit/src/test/java/com/babylon/orbit/OrbitDSLSpek.kt +++ b/orbit/src/test/java/com/babylon/orbit/DslSpek.kt @@ -17,7 +17,6 @@ package com.babylon.orbit import io.reactivex.observers.TestObserver -import io.reactivex.plugins.RxJavaPlugins import io.reactivex.subjects.PublishSubject import org.assertj.core.api.Assertions import org.assertj.core.api.Assertions.assertThat @@ -25,11 +24,15 @@ import org.spekframework.spek2.Spek import org.spekframework.spek2.style.gherkin.Feature import java.util.concurrent.CountDownLatch -internal class OrbitDSLSpek : Spek({ +internal class DslSpek : Spek({ Feature("DSL - syntax") { createTestMiddleware { + configuration { + sideEffectCachingEnabled = true + } + perform("something") .on() .withReducer { getCurrentState().copy(id = getCurrentState().id + event) } @@ -49,6 +52,50 @@ internal class OrbitDSLSpek : Spek({ } } + Feature("DSL - configuration") { + Scenario("Set the caching to false and build the middleware") { + lateinit var middleware: Middleware + var sideEffectCaching = true + + Given("middleware configured to disable side effect caching") { + middleware = createTestMiddleware { + configuration { + sideEffectCachingEnabled = false + } + } + } + + When("I query the configuration for side effect caching") { + sideEffectCaching = middleware.configuration.sideEffectCachingEnabled + } + + Then("Side effect caching should be disabled") { + assertThat(sideEffectCaching).isFalse() + } + } + + Scenario("Set the caching to true and build the middleware") { + lateinit var middleware: Middleware + var sideEffectCaching = false + + Given("middleware configured to disable side effect caching") { + middleware = createTestMiddleware { + configuration { + sideEffectCachingEnabled = true + } + } + } + + When("I query the configuration for side effect caching") { + sideEffectCaching = middleware.configuration.sideEffectCachingEnabled + } + + Then("Side effect caching should be enabled") { + assertThat(sideEffectCaching).isTrue() + } + } + } + Feature("DSL - tests") { Scenario("a flow that reduces an action") { diff --git a/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt b/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt index d0cb88cb..e9d7c692 100644 --- a/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt +++ b/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt @@ -153,8 +153,8 @@ internal class OrbitContainerSpek : Spek({ } } - Feature("Container - Side Effects") { - Scenario("Side effects are multicast to all current observers") { + Feature("Container - Cached Side Effects") { + Scenario("Side effects are multicast to all current observers by default") { lateinit var middleware: Middleware lateinit var orbitContainer: BaseOrbitContainer lateinit var testObserver1: TestObserver @@ -192,8 +192,7 @@ internal class OrbitContainerSpek : Spek({ } } - // Is this the responsibility of this library? - Scenario("Side effects are cached while there is no connected observer") { + Scenario("Side effects are cached while there is no connected observer by default") { lateinit var middleware: Middleware lateinit var orbitContainer: BaseOrbitContainer lateinit var stateObserver: TestObserver @@ -218,7 +217,7 @@ internal class OrbitContainerSpek : Spek({ And("I connect the side effect observer to the middleware") { sideEffectObserver = orbitContainer.sideEffect.test() - sideEffectObserver.awaitCount(1) + sideEffectObserver.awaitCount(2) } Then("The observer gets the side effect") { @@ -226,7 +225,131 @@ internal class OrbitContainerSpek : Spek({ } } - Scenario("Cached side effects are guaranteed to be delivered to the first observer") { + Scenario("If I connect, disconnect and reconnect the side effects behave correctly by default") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var stateObserver: TestObserver + lateinit var sideEffectObserver: TestObserver + + Given("A middleware with no flows") { + middleware = createTestMiddleware { + perform("send side effect") + .on() + .sideEffect { post("foo") } + .sideEffect { post("bar") } + .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("I send an event to the container") { + stateObserver = orbitContainer.orbit.test() + sideEffectObserver = orbitContainer.sideEffect.test() + orbitContainer.sendAction(Unit) + stateObserver.awaitCount(2) + sideEffectObserver.awaitCount(2) + } + + Then("The observer gets the side effects") { + sideEffectObserver.assertValueSequence(listOf("foo", "bar")) + } + + When("I disconnect") { + stateObserver.dispose() + sideEffectObserver.dispose() + } + + And("I send an event to the container") { + orbitContainer.sendAction(Unit) + } + + And("I resubscribe") { + stateObserver = orbitContainer.orbit.test() + sideEffectObserver = orbitContainer.sideEffect.test() + stateObserver.awaitCount(1) + sideEffectObserver.awaitCount(2) + } + + Then("The new observer gets the side effects") { + sideEffectObserver.assertValueSequence(listOf("foo", "bar")) + } + + When("I send another event") { + orbitContainer.sendAction(Unit) + stateObserver.awaitCount(2) + sideEffectObserver.awaitCount(4) + } + + Then("The observer gets the side effects again") { + sideEffectObserver.assertValueSequence(listOf("foo", "bar", "foo", "bar")) + } + } + + Scenario("If I connect, disconnect and reconnect the side effects behave correctly" + + " when explicitly set to true") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var stateObserver: TestObserver + lateinit var sideEffectObserver: TestObserver + + Given("A middleware with no flows") { + middleware = createTestMiddleware { + configuration { + sideEffectCachingEnabled = true + } + perform("send side effect") + .on() + .sideEffect { post("foo") } + .sideEffect { post("bar") } + .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("I send an event to the container") { + stateObserver = orbitContainer.orbit.test() + sideEffectObserver = orbitContainer.sideEffect.test() + orbitContainer.sendAction(Unit) + stateObserver.awaitCount(2) + sideEffectObserver.awaitCount(2) + } + + Then("The observer gets the side effects") { + sideEffectObserver.assertValueSequence(listOf("foo", "bar")) + } + + When("I disconnect") { + stateObserver.dispose() + sideEffectObserver.dispose() + } + + And("I send an event to the container") { + orbitContainer.sendAction(Unit) + } + + And("I resubscribe") { + stateObserver = orbitContainer.orbit.test() + sideEffectObserver = orbitContainer.sideEffect.test() + stateObserver.awaitCount(1) + sideEffectObserver.awaitCount(2) + } + + Then("The new observer gets the side effects") { + sideEffectObserver.assertValueSequence(listOf("foo", "bar")) + } + + When("I send another event") { + orbitContainer.sendAction(Unit) + stateObserver.awaitCount(2) + sideEffectObserver.awaitCount(4) + } + + Then("The observer gets the side effects again") { + sideEffectObserver.assertValueSequence(listOf("foo", "bar", "foo", "bar")) + } + } + + Scenario("Cached side effects are guaranteed to be delivered to the first observer by default") { lateinit var middleware: Middleware lateinit var orbitContainer: BaseOrbitContainer @@ -267,6 +390,85 @@ internal class OrbitContainerSpek : Spek({ } } + Feature("Container - uncached Side Effects") { + Scenario("Side effects are multicast to all current observers") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var testObserver1: TestObserver + lateinit var testObserver2: TestObserver + + Given("A middleware with no flows") { + middleware = createTestMiddleware { + configuration { + sideEffectCachingEnabled = false + } + perform("send side effect") + .on() + .sideEffect { post("foobar") } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("I connect observer 1 to the middleware") { + testObserver1 = orbitContainer.sideEffect.test() + } + + And("I connect observer 2 to the middleware") { + testObserver2 = orbitContainer.sideEffect.test() + } + + And("I send an event to the container") { + orbitContainer.sendAction(Unit) + testObserver1.awaitCount(1) + testObserver2.awaitCount(1) + } + + Then("Observer 1 gets the side effect") { + testObserver1.assertValueSequence(listOf("foobar")) + } + + And("Observer 2 gets the side effect") { + testObserver2.assertValueSequence(listOf("foobar")) + } + } + + Scenario("Side effects are not cached while there is no connected observer") { + lateinit var middleware: Middleware + lateinit var orbitContainer: BaseOrbitContainer + lateinit var stateObserver: TestObserver + lateinit var sideEffectObserver: TestObserver + + Given("A middleware with no flows") { + middleware = createTestMiddleware { + configuration { + sideEffectCachingEnabled = false + } + perform("send side effect") + .on() + .sideEffect { post("foo") } + .sideEffect { post("bar") } + .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + } + orbitContainer = BaseOrbitContainer(middleware) + } + + When("I send an event to the container") { + stateObserver = orbitContainer.orbit.test() + orbitContainer.sendAction(Unit) + stateObserver.awaitCount(2) + } + + And("I connect the side effect observer to the middleware") { + sideEffectObserver = orbitContainer.sideEffect.test() + sideEffectObserver.awaitCount(2) + } + + Then("The observer does not get the side effects") { + sideEffectObserver.assertNoValues() + } + } + } + Feature("Container - Lifecycle") { Scenario("Lifecycle action sent on container creation") { lateinit var middleware: Middleware diff --git a/orbit/src/test/java/com/babylon/orbit/OrbitContainerThreadingSpek.kt b/orbit/src/test/java/com/babylon/orbit/OrbitContainerThreadingSpek.kt index efdbc0c3..faf13148 100644 --- a/orbit/src/test/java/com/babylon/orbit/OrbitContainerThreadingSpek.kt +++ b/orbit/src/test/java/com/babylon/orbit/OrbitContainerThreadingSpek.kt @@ -68,7 +68,6 @@ internal class OrbitContainerThreadingSpek : Spek({ Then("The side effect runs on the reducer thread") { assertThat(sideEffectThreadName).isEqualTo("reducerThread") } - } Scenario("Reducers execute on reducer thread") { @@ -267,7 +266,6 @@ internal class OrbitContainerThreadingSpek : Spek({ testObserver.awaitCount(3) } - Then("The first reducer runs on the reducer thread") { assertThat(firstReducerThreadName).isEqualTo("reducerThread") } diff --git a/orbit/src/test/java/com/babylon/orbit/TestState.kt b/orbit/src/test/java/com/babylon/orbit/TestState.kt new file mode 100644 index 00000000..476788fb --- /dev/null +++ b/orbit/src/test/java/com/babylon/orbit/TestState.kt @@ -0,0 +1,3 @@ +package com.babylon.orbit + +data class TestState(val id: Int) diff --git a/orbit/src/test/java/com/babylon/orbit/TestUtil.kt b/orbit/src/test/java/com/babylon/orbit/TestUtil.kt index 73ad648a..f9b390e9 100644 --- a/orbit/src/test/java/com/babylon/orbit/TestUtil.kt +++ b/orbit/src/test/java/com/babylon/orbit/TestUtil.kt @@ -6,5 +6,3 @@ fun createTestMiddleware( ) = middleware(initialState) { this.apply(block) } - -data class TestState(val id: Int) \ No newline at end of file From ac7b57e9517df95748d1069f04999ba99131fb05 Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Mon, 11 Nov 2019 16:09:40 +0100 Subject: [PATCH 16/19] Fixed failing test --- orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt b/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt index e9d7c692..35643295 100644 --- a/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt +++ b/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt @@ -486,6 +486,7 @@ internal class OrbitContainerSpek : Spek({ When("I connect to the middleware") { testObserver = orbitContainer.orbit.test() + testObserver.awaitCount(1) } Then("I get the modified state") { From fc1899d605dce37c15daac98005f23c7cbfab437 Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Mon, 11 Nov 2019 16:20:06 +0100 Subject: [PATCH 17/19] Fixed failing test --- .../com/babylon/orbit/OrbitContainerSpek.kt | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt b/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt index 35643295..68f1f569 100644 --- a/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt +++ b/orbit/src/test/java/com/babylon/orbit/OrbitContainerSpek.kt @@ -17,6 +17,7 @@ package com.babylon.orbit import io.reactivex.observers.TestObserver +import io.reactivex.subjects.PublishSubject import org.assertj.core.api.Assertions.assertThat import org.spekframework.spek2.Spek import org.spekframework.spek2.style.gherkin.Feature @@ -78,7 +79,7 @@ internal class OrbitContainerSpek : Spek({ lateinit var testObserver1: TestObserver lateinit var testObserver2: TestObserver - Given("A middleware with no flows") { + Given("A middleware with a reducer") { middleware = createTestMiddleware { perform("increment id") .on() @@ -119,7 +120,7 @@ internal class OrbitContainerSpek : Spek({ lateinit var state1: TestState lateinit var state2: TestState - Given("A middleware with no flows") { + Given("A middleware with a reducer") { middleware = createTestMiddleware { perform("increment id") .on() @@ -160,7 +161,7 @@ internal class OrbitContainerSpek : Spek({ lateinit var testObserver1: TestObserver lateinit var testObserver2: TestObserver - Given("A middleware with no flows") { + Given("A middleware with side effect") { middleware = createTestMiddleware { perform("send side effect") .on() @@ -198,7 +199,7 @@ internal class OrbitContainerSpek : Spek({ lateinit var stateObserver: TestObserver lateinit var sideEffectObserver: TestObserver - Given("A middleware with no flows") { + Given("A middleware with side effects") { middleware = createTestMiddleware { perform("send side effect") .on() @@ -231,7 +232,7 @@ internal class OrbitContainerSpek : Spek({ lateinit var stateObserver: TestObserver lateinit var sideEffectObserver: TestObserver - Given("A middleware with no flows") { + Given("A middleware with side effects") { middleware = createTestMiddleware { perform("send side effect") .on() @@ -285,14 +286,16 @@ internal class OrbitContainerSpek : Spek({ } } - Scenario("If I connect, disconnect and reconnect the side effects behave correctly" + - " when explicitly set to true") { + Scenario( + "If I connect, disconnect and reconnect the side effects behave correctly" + + " when explicitly set to true" + ) { lateinit var middleware: Middleware lateinit var orbitContainer: BaseOrbitContainer lateinit var stateObserver: TestObserver lateinit var sideEffectObserver: TestObserver - Given("A middleware with no flows") { + Given("A middleware with side effects") { middleware = createTestMiddleware { configuration { sideEffectCachingEnabled = true @@ -357,7 +360,7 @@ internal class OrbitContainerSpek : Spek({ lateinit var sideEffectObserver1: TestObserver lateinit var sideEffectObserver2: TestObserver - Given("A middleware with no flows") { + Given("A middleware with side effects") { middleware = createTestMiddleware { perform("send side effect") .on() @@ -397,7 +400,7 @@ internal class OrbitContainerSpek : Spek({ lateinit var testObserver1: TestObserver lateinit var testObserver2: TestObserver - Given("A middleware with no flows") { + Given("A middleware with side effect") { middleware = createTestMiddleware { configuration { sideEffectCachingEnabled = false @@ -438,7 +441,7 @@ internal class OrbitContainerSpek : Spek({ lateinit var stateObserver: TestObserver lateinit var sideEffectObserver: TestObserver - Given("A middleware with no flows") { + Given("A middleware with side effects") { middleware = createTestMiddleware { configuration { sideEffectCachingEnabled = false @@ -472,25 +475,24 @@ internal class OrbitContainerSpek : Spek({ Feature("Container - Lifecycle") { Scenario("Lifecycle action sent on container creation") { lateinit var middleware: Middleware - lateinit var orbitContainer: BaseOrbitContainer - lateinit var testObserver: TestObserver + val sideEffectSubject = PublishSubject.create() + val sideEffectTestObserver = sideEffectSubject.test() - Given("A middleware with no flows") { + Given("A middleware with a side effect off a LifecycleEvent.Created") { middleware = createTestMiddleware { perform("check lifecycle action") .on() - .withReducer { getCurrentState().copy(id = getCurrentState().id + 1) } + .sideEffect { sideEffectSubject.onNext("foo") } } - orbitContainer = BaseOrbitContainer(middleware) + BaseOrbitContainer(middleware) } When("I connect to the middleware") { - testObserver = orbitContainer.orbit.test() - testObserver.awaitCount(1) + sideEffectTestObserver.awaitCount(1) } - Then("I get the modified state") { - testObserver.assertValueSequence(listOf(TestState(43))) + Then("The side effect is received") { + sideEffectTestObserver.assertValue("foo") } } } From 7d09600366173c99351d8b1845d18d37299fa52f Mon Sep 17 00:00:00 2001 From: "mikolaj.leszczynski" Date: Mon, 11 Nov 2019 21:00:13 +0100 Subject: [PATCH 18/19] #20 #28, renamed `withReducer` to `reduce`, added missing copyright notices --- .idea/codeStyles/Project.xml | 3 + .idea/copyright/Babylon.xml | 6 + README.md | 4 +- buildSrc/build.gradle.kts | 4 +- docs/orbits.md | 194 ++++++++++++------ gradle/wrapper/gradle-wrapper.properties | 16 ++ .../orbit/OrbitViewModelLifecycleTest.kt | 24 ++- .../orbit/OrbitViewModelThreadingTest.kt | 18 +- .../test/java/com/babylon/orbit/TestState.kt | 16 ++ .../test/java/com/babylon/orbit/TestUtil.kt | 16 ++ orbit/orbit_build.gradle.kts | 4 +- orbit/src/main/java/com/babylon/orbit/Dsl.kt | 131 +++++++++++- .../test/java/com/babylon/orbit/DslSpek.kt | 22 +- .../com/babylon/orbit/OrbitContainerSpek.kt | 14 +- .../orbit/OrbitContainerThreadingSpek.kt | 6 +- .../test/java/com/babylon/orbit/TestState.kt | 16 ++ .../test/java/com/babylon/orbit/TestUtil.kt | 16 ++ .../sample/presentation/TodoViewModel.kt | 12 +- 18 files changed, 418 insertions(+), 104 deletions(-) create mode 100644 .idea/copyright/Babylon.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index a700adc5..b58d0ca5 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,5 +1,8 @@ + +