From 3ca8c3cf34a4bb2a3708ccccef2fabd7b41556d6 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Mon, 24 Aug 2020 22:58:42 +0200 Subject: [PATCH 01/12] Add EffectController implementation --- CHANGELOG.md | 4 + .../at/florianschuster/control/Controller.kt | 4 +- .../control/EffectController.kt | 144 ++++++++++++++++++ .../at/florianschuster/control/errors.kt | 16 +- .../at/florianschuster/control/event.kt | 9 +- .../florianschuster/control/implementation.kt | 76 ++++++--- .../kotlin/at/florianschuster/control/stub.kt | 47 +++++- .../control/CreateControllerTest.kt | 2 +- .../control/CreateEffectControllerTest.kt | 36 +++++ .../at/florianschuster/control/EventTest.kt | 12 +- .../control/ImplementationTest.kt | 34 ++++- .../at/florianschuster/control/StartTest.kt | 2 +- .../at/florianschuster/control/StubTest.kt | 2 +- 13 files changed, 350 insertions(+), 38 deletions(-) create mode 100644 control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt create mode 100644 control-core/src/test/kotlin/at/florianschuster/control/CreateEffectControllerTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ab45076..660ec90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ - binary compatibility will now be verified and held up on every release. +## `[0.13.0]` - 2020-08-XX + +- Add `EffectController` and `CoroutineScope.createEffectController`. + ## `[0.12.0]` - 2020-08-24 - Remove `Mutation` generic type from `Controller` interface. diff --git a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt index b4ed031..44c55ff 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow -import kotlin.coroutines.ContinuationInterceptor /** * A [Controller] is an UI-independent class that controls the state of a view. The role of a @@ -140,7 +139,7 @@ fun CoroutineScope.createController( * [Mutator] and [Reducer] will run on this [CoroutineDispatcher]. */ dispatcher: CoroutineDispatcher = defaultScopeDispatcher() -): Controller = ControllerImplementation( +): Controller = ControllerImplementation( scope = this, dispatcher = dispatcher, controllerStart = controllerStart, initialState = initialState, mutator = mutator, reducer = reducer, @@ -262,7 +261,6 @@ interface ReducerContext */ typealias Transformer = TransformerContext.(emissions: Flow) -> Flow - /** * A context used for a [Transformer]. Does not provide any additional functionality. */ diff --git a/control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt b/control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt new file mode 100644 index 0000000..f4e717c --- /dev/null +++ b/control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt @@ -0,0 +1,144 @@ +package at.florianschuster.control + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +/** + * An [EffectProvider] provides a [Flow] of (side-)effects that can happen during a + * mutation in [Mutator] or a state reduction in [Reducer]. + */ +interface EffectProvider { + + /** + * [Flow] of [Effect]s. Use this to collect [Effect] emissions. + * The [Flow] is received in a fan-out fashion: one emission is just collected once. + */ + val effects: Flow +} + +/** + * An [EffectEmitter] emits side-effects. + * + * This is implemented by the respective context's of [EffectMutator], [EffectReducer] + * and [EffectTransformer]. + */ +interface EffectEmitter { + + /** + * Emits an [Effect]. + */ + fun offerEffect(effect: Effect) +} + +/** + * A [Controller] that provides a [Flow] of [Effect]'s via [EffectProvider]. + * + * Before using this, make sure to look into the [Controller] documentation. + */ +interface EffectController : Controller, + EffectProvider + +/** + * Creates an [EffectController] bound to the [CoroutineScope] via [ControllerImplementation]. + * + * The principle of the created state machine is the same as with + * [CoroutineScope.createController]. + * + * An [Effect] can be emitted either in [mutator], [reducer], [actionsTransformer], + * [mutationsTransformer] or [statesTransformer]. + */ +@ExperimentalCoroutinesApi +@FlowPreview +fun CoroutineScope.createEffectController( + + /** + * The initial [State] for the internal state machine. + */ + initialState: State, + /** + * See [EffectMutator]. + */ + mutator: EffectMutator = { _ -> emptyFlow() }, + /** + * See [EffectReducer]. + */ + reducer: EffectReducer = { _, previousState -> previousState }, + + /** + * See [EffectTransformer]. + */ + actionsTransformer: EffectTransformer = { it }, + mutationsTransformer: EffectTransformer = { it }, + statesTransformer: EffectTransformer = { it }, + + /** + * Used for [ControllerLog] and as [CoroutineName] for the internal state machine. + */ + tag: String = defaultTag(), + /** + * Log configuration for [ControllerEvent]s. See [ControllerLog]. + */ + controllerLog: ControllerLog = ControllerLog.default, + + /** + * When the internal state machine [Flow] should be started. See [ControllerStart]. + */ + controllerStart: ControllerStart = ControllerStart.Lazy, + + /** + * Override to launch the internal state machine [Flow] in a different [CoroutineDispatcher] + * than the one used in the [CoroutineScope.coroutineContext]. + * + * [Mutator] and [Reducer] will run on this [CoroutineDispatcher]. + */ + dispatcher: CoroutineDispatcher = defaultScopeDispatcher() +): EffectController = ControllerImplementation( + scope = this, dispatcher = dispatcher, controllerStart = controllerStart, + + initialState = initialState, mutator = mutator, reducer = reducer, + actionsTransformer = actionsTransformer, + mutationsTransformer = mutationsTransformer, + statesTransformer = statesTransformer, + + tag = tag, controllerLog = controllerLog +) + +/** + * A [Mutator] used in a [EffectController] that is able to emit effects. + */ +typealias EffectMutator = + EffectMutatorContext.(action: Action) -> Flow + +/** + * A [MutatorContext] that additionally provides the functionality of an [EffectEmitter]. + * This context is used for an [EffectMutator]. + */ +interface EffectMutatorContext : + MutatorContext, EffectEmitter + +/** + * A [Reducer] used in a [EffectController] that is able to emit effects. + */ +typealias EffectReducer = + EffectReducerContext.(mutation: Mutation, previousState: State) -> State + +/** + * A [ReducerContext] that additionally provides the functionality of an [EffectEmitter]. + */ +interface EffectReducerContext : ReducerContext, EffectEmitter + +/** + * A [Transformer] used in a [EffectController] that is able to emit effects. + */ +typealias EffectTransformer = + EffectTransformerContext.(emissions: Flow) -> Flow + +/** + * A [TransformerContext] that additionally provides the functionality of an [EffectEmitter]. + */ +interface EffectTransformerContext : TransformerContext, EffectEmitter \ No newline at end of file diff --git a/control-core/src/main/kotlin/at/florianschuster/control/errors.kt b/control-core/src/main/kotlin/at/florianschuster/control/errors.kt index 1ab3d3b..d683039 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/errors.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/errors.kt @@ -15,7 +15,7 @@ internal sealed class ControllerError( tag: String, action: String, cause: Throwable - ) : ControllerError("Mutator error in $tag, action = $action", cause) + ) : ControllerError(message = "Mutator error in $tag, action = $action", cause = cause) /** * Error during [Reducer]. @@ -29,4 +29,18 @@ internal sealed class ControllerError( message = "Reducer error in $tag, previousState = $previousState, mutation = $mutation", cause = cause ) + + /** + * Error during [EffectEmitter.offerEffect]. + */ + class Effect( + tag: String, + effect: String + ) : ControllerError( + message = "Effect error in $tag, effect = $effect", + cause = IllegalStateException( + "Capacity for effects has been reached. Either too many effects have been triggered " + + "or they might not be consumed." + ) + ) } diff --git a/control-core/src/main/kotlin/at/florianschuster/control/event.kt b/control-core/src/main/kotlin/at/florianschuster/control/event.kt index 319050d..8dcb335 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/event.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/event.kt @@ -30,7 +30,7 @@ sealed class ControllerEvent( ) : ControllerEvent(tag, "action: $action") /** - * When the [Mutator] produces a new [Mutation]. + * When the [Mutator] emits a new [Mutation]. */ class Mutation internal constructor( tag: String, mutation: String @@ -43,6 +43,13 @@ sealed class ControllerEvent( tag: String, state: String ) : ControllerEvent(tag, "state: $state") + /** + * When an [Effect] is emitted by the [EffectController]. + */ + class Effect internal constructor( + tag: String, effect: String + ) : ControllerEvent(tag, "effect: $effect") + /** * When an error happens during the execution of the internal state machine. */ diff --git a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt index 12c3555..c1fa649 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt @@ -8,16 +8,19 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BroadcastChannel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.BUFFERED import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.scan import kotlinx.coroutines.launch @@ -26,22 +29,22 @@ import kotlinx.coroutines.launch */ @ExperimentalCoroutinesApi @FlowPreview -internal class ControllerImplementation( +internal class ControllerImplementation( val scope: CoroutineScope, val dispatcher: CoroutineDispatcher, val controllerStart: ControllerStart, val initialState: State, - val mutator: Mutator, - val reducer: Reducer, + val mutator: EffectMutator, + val reducer: EffectReducer, - val actionsTransformer: Transformer, - val mutationsTransformer: Transformer, - val statesTransformer: Transformer, + val actionsTransformer: EffectTransformer, + val mutationsTransformer: EffectTransformer, + val statesTransformer: EffectTransformer, val tag: String, val controllerLog: ControllerLog -) : Controller { +) : EffectController { // region state machine @@ -52,11 +55,15 @@ internal class ControllerImplementation( context = dispatcher + CoroutineName(tag), start = CoroutineStart.LAZY ) { - val transformerContext = createTransformerContext() + val transformerContext = createTransformerContext(effectEmitter) val actionFlow: Flow = transformerContext.actionsTransformer(actionChannel.asFlow()) - val mutatorContext = createMutatorContext({ currentState }, actionFlow) + val mutatorContext = createMutatorContext( + stateAccessor = { currentState }, + actionFlow = actionFlow, + effectEmitter = effectEmitter + ) val mutationFlow: Flow = actionFlow.flatMapMerge { action -> controllerLog.log { ControllerEvent.Action(tag, action.toString()) } @@ -67,7 +74,7 @@ internal class ControllerImplementation( } } - val reducerContext = createReducerContext() + val reducerContext = createReducerContext(effectEmitter) val stateFlow: Flow = transformerContext.mutationsTransformer(mutationFlow) .onEach { controllerLog.log { ControllerEvent.Mutation(tag, it.toString()) } } @@ -93,9 +100,29 @@ internal class ControllerImplementation( // endregion + // region effects + + private val effectEmitter: (Effect) -> Unit = { effect -> + controllerLog.log { ControllerEvent.Effect(tag, effect.toString()) } + val canBeOffered = effectsChannel.offer(effect) + if (!canBeOffered) { + val error = ControllerError.Effect(tag, effect.toString()) + controllerLog.log { ControllerEvent.Error(tag, error) } + throw error + } + } + + private val effectsChannel = Channel(BUFFERED) + override val effects: Flow + get() = if (stubInitialized) stub.effectChannel.asFlow() else { + effectsChannel.receiveAsFlow().cancellable() + } + + // endregion + // region stub - internal lateinit var stub: ControllerStubImplementation + internal lateinit var stub: ControllerStubImplementation internal val stubInitialized: Boolean get() = this::stub.isInitialized // endregion @@ -145,16 +172,27 @@ internal class ControllerImplementation( } companion object { - fun createMutatorContext( + internal fun createMutatorContext( stateAccessor: () -> State, - actionFlow: Flow - ): MutatorContext = object : MutatorContext { - override val currentState: State get() = stateAccessor() - override val actions: Flow = actionFlow - } + actionFlow: Flow, + effectEmitter: (Effect) -> Unit + ): EffectMutatorContext = + object : EffectMutatorContext { + override val currentState: State get() = stateAccessor() + override val actions: Flow = actionFlow + override fun offerEffect(effect: Effect) = effectEmitter(effect) + } - fun createReducerContext(): ReducerContext = object : ReducerContext {} + internal fun createReducerContext( + emitter: (Effect) -> Unit + ): EffectReducerContext = object : EffectReducerContext { + override fun offerEffect(effect: Effect) = emitter(effect) + } - fun createTransformerContext(): TransformerContext = object : TransformerContext {} + internal fun createTransformerContext( + emitter: (Effect) -> Unit + ): EffectTransformerContext = object : EffectTransformerContext { + override fun offerEffect(effect: Effect) = emitter(effect) + } } } diff --git a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt index 73265dd..668ca6e 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt @@ -2,6 +2,8 @@ package at.florianschuster.control import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.BroadcastChannel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED import kotlinx.coroutines.flow.MutableStateFlow import org.jetbrains.annotations.TestOnly @@ -15,12 +17,12 @@ import org.jetbrains.annotations.TestOnly @FlowPreview @TestOnly fun Controller.stub(): ControllerStub { - require(this is ControllerImplementation) { + require(this is ControllerImplementation) { "Cannot stub a custom implementation of a Controller." } if (!stubInitialized) { controllerLog.log { ControllerEvent.Stub(tag) } - stub = ControllerStubImplementation(initialState) + stub = ControllerStubImplementation(initialState) } return stub } @@ -43,20 +45,57 @@ interface ControllerStub { fun emitState(state: State) } +/** + * Retrieves a [ControllerStub] for this [Controller] used for view testing. + * Once accessed, the [Controller] is stubbed and cannot be un-stubbed. + * + * Custom implementations of [Controller] cannot be stubbed. + */ +@ExperimentalCoroutinesApi +@FlowPreview +@TestOnly +fun EffectController.effectStub(): EffectControllerStub { + require(this is ControllerImplementation) { + "Cannot stub a custom implementation of a Controller." + } + if (!stubInitialized) { + controllerLog.log { ControllerEvent.Stub(tag) } + stub = ControllerStubImplementation(initialState) + } + return stub +} + +/** + * A stub of a [EffectController] for view testing. + */ +interface EffectControllerStub : ControllerStub { + + /** + * Emits a new [Effect] for [EffectController.effects]. + * Use this to verify if [Effect] is correctly bound to a view. + */ + fun emitEffect(effect: Effect) +} + /** * An implementation of [ControllerStub]. */ @ExperimentalCoroutinesApi -internal class ControllerStubImplementation( +internal class ControllerStubImplementation( initialState: State -) : ControllerStub { +) : EffectControllerStub { internal val mutableDispatchedActions = mutableListOf() internal val stateFlow = MutableStateFlow(initialState) + internal val effectChannel = BroadcastChannel(BUFFERED) override val dispatchedActions: List get() = mutableDispatchedActions override fun emitState(state: State) { stateFlow.value = state } + + override fun emitEffect(effect: Effect) { + effectChannel.offer(effect) + } } diff --git a/control-core/src/test/kotlin/at/florianschuster/control/CreateControllerTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/CreateControllerTest.kt index 16853d8..8e71ebe 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/CreateControllerTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/CreateControllerTest.kt @@ -15,7 +15,7 @@ internal class CreateControllerTest { val expectedInitialState = 42 val sut = createController( initialState = expectedInitialState - ) as ControllerImplementation + ) as ControllerImplementation assertEquals(this, sut.scope) assertEquals(expectedInitialState, sut.initialState) diff --git a/control-core/src/test/kotlin/at/florianschuster/control/CreateEffectControllerTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/CreateEffectControllerTest.kt new file mode 100644 index 0000000..cbca922 --- /dev/null +++ b/control-core/src/test/kotlin/at/florianschuster/control/CreateEffectControllerTest.kt @@ -0,0 +1,36 @@ +package at.florianschuster.control + +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.singleOrNull +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Test +import kotlin.test.assertEquals + +internal class CreateEffectControllerTest { + + @Test + fun `default parameters of controller builder`() = runBlockingTest { + val expectedInitialState = 42 + val sut = createEffectController( + initialState = expectedInitialState + ) as ControllerImplementation + + assertEquals(this, sut.scope) + assertEquals(expectedInitialState, sut.initialState) + + assertEquals(null, sut.mutator(mockk(), 3).singleOrNull()) + assertEquals(1, sut.reducer(mockk(), 0, 1)) + + assertEquals(1, sut.actionsTransformer(mockk(), flowOf(1)).single()) + assertEquals(2, sut.mutationsTransformer(mockk(), flowOf(2)).single()) + assertEquals(3, sut.statesTransformer(mockk(), flowOf(3)).single()) + + assertEquals(defaultTag(), sut.tag) + assertEquals(ControllerLog.default, sut.controllerLog) + + assertEquals(ControllerStart.Lazy, sut.controllerStart) + assertEquals(defaultScopeDispatcher(), sut.dispatcher) + } +} diff --git a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt index 70530f2..1c0e188 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt @@ -41,6 +41,14 @@ internal class EventTest { assertTrue(lastEvents[2] is ControllerEvent.State) } + sut.dispatch(effectValue) + events.takeLast(4).let { lastEvents -> + assertTrue(lastEvents[0] is ControllerEvent.Action) + assertTrue(lastEvents[1] is ControllerEvent.Effect) + assertTrue(lastEvents[2] is ControllerEvent.Mutation) + assertTrue(lastEvents[3] is ControllerEvent.State) + } + sut.stub() assertTrue(events.last() is ControllerEvent.Stub) @@ -75,13 +83,14 @@ internal class EventTest { private fun CoroutineScope.eventsController( events: MutableList, controllerStart: ControllerStart = ControllerStart.Lazy - ) = ControllerImplementation( + ) = ControllerImplementation( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = controllerStart, initialState = 0, mutator = { action -> flow { + if (action == effectValue) offerEffect(effectValue) check(action != mutatorErrorValue) emit(action) } @@ -100,5 +109,6 @@ internal class EventTest { companion object { private const val mutatorErrorValue = 42 private const val reducerErrorValue = 69 + private const val effectValue = 420 } } \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt index 7a09e7d..5249bf6 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt @@ -184,10 +184,32 @@ internal class ImplementationTest { fun `MutatorContext is built correctly`() { val stateAccessor = { 1 } val actions = flowOf(1) - val sut = ControllerImplementation.createMutatorContext(stateAccessor, actions) + val effectEmitter: (Int) -> Unit = { _ -> } + val sut = ControllerImplementation.createMutatorContext( + stateAccessor, + actions, + effectEmitter + ) assertEquals(stateAccessor(), sut.currentState) assertEquals(actions, sut.actions) + assertEquals(effectEmitter, sut::offerEffect) + } + + @Test + fun `ReducerContext is built correctly`() { + val effectEmitter: (Int) -> Unit = { _ -> } + val sut = ControllerImplementation.createReducerContext(effectEmitter) + + assertEquals(effectEmitter, sut::offerEffect) + } + + @Test + fun `TransformerContext is built correctly`() { + val effectEmitter: (Int) -> Unit = { _ -> } + val sut = ControllerImplementation.createTransformerContext(effectEmitter) + + assertEquals(effectEmitter, sut::offerEffect) } @Test @@ -207,7 +229,7 @@ internal class ImplementationTest { } private fun CoroutineScope.createAlwaysSameStateController() = - ControllerImplementation( + ControllerImplementation( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = ControllerStart.Lazy, @@ -222,7 +244,7 @@ internal class ImplementationTest { ) private fun CoroutineScope.createOperationController() = - ControllerImplementation, List, List>( + ControllerImplementation, List, List, Nothing>( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = ControllerStart.Lazy, @@ -258,7 +280,7 @@ internal class ImplementationTest { private fun CoroutineScope.createCounterController( mutatorErrorIndex: Int? = null, reducerErrorIndex: Int? = null - ) = ControllerImplementation( + ) = ControllerImplementation( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = ControllerStart.Lazy, @@ -286,7 +308,7 @@ internal class ImplementationTest { } private fun CoroutineScope.createStopWatchController() = - ControllerImplementation( + ControllerImplementation( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = ControllerStart.Lazy, @@ -314,7 +336,7 @@ internal class ImplementationTest { private fun CoroutineScope.createGlobalStateMergeController( globalState: Flow - ) = ControllerImplementation( + ) = ControllerImplementation( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = ControllerStart.Lazy, diff --git a/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt index 44d3aee..818acb3 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt @@ -143,7 +143,7 @@ internal class StartTest { private fun CoroutineScope.createSimpleCounterController( controllerStart: ControllerStart - ) = ControllerImplementation( + ) = ControllerImplementation( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = controllerStart, diff --git a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt index fecb11b..84e2947 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt @@ -101,7 +101,7 @@ internal class StubTest { } private fun CoroutineScope.createStringController() = - ControllerImplementation, List, List>( + ControllerImplementation, List, List, Nothing>( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = ControllerStart.Lazy, From 892edfdf5c85fe1acd94f047eefd4c7711ae5232 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sat, 29 Aug 2020 13:11:50 +0200 Subject: [PATCH 02/12] Refactor stub and add tests for EffectController --- CHANGELOG.md | 1 + control-core/api/control-core.api | 41 +++++- control-core/build.gradle.kts | 5 +- .../control/EffectController.kt | 51 ++++---- .../at/florianschuster/control/extensions.kt | 8 ++ .../florianschuster/control/implementation.kt | 43 ++++--- .../kotlin/at/florianschuster/control/stub.kt | 68 ++++------ .../at/florianschuster/control/EventTest.kt | 2 +- .../florianschuster/control/ExtensionsTest.kt | 13 ++ .../control/ImplementationTest.kt | 117 +++++++++++++++--- .../at/florianschuster/control/StubTest.kt | 31 +++-- .../control/androidcounter/CounterViewTest.kt | 7 +- .../androidgithub/search/GithubViewTest.kt | 11 +- .../androidgithub/search/GithubView.kt | 1 - 14 files changed, 263 insertions(+), 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 660ec90..edf59a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## `[0.13.0]` - 2020-08-XX - Add `EffectController` and `CoroutineScope.createEffectController`. +- Rename `Controller.stub()` to `Controller.toStub()` to better reflect what it is doing. ## `[0.12.0]` - 2020-08-24 diff --git a/control-core/api/control-core.api b/control-core/api/control-core.api index 13b5bd5..6561509 100644 --- a/control-core/api/control-core.api +++ b/control-core/api/control-core.api @@ -18,6 +18,9 @@ public final class at/florianschuster/control/ControllerEvent$Completed : at/flo public final class at/florianschuster/control/ControllerEvent$Created : at/florianschuster/control/ControllerEvent { } +public final class at/florianschuster/control/ControllerEvent$Effect : at/florianschuster/control/ControllerEvent { +} + public final class at/florianschuster/control/ControllerEvent$Error : at/florianschuster/control/ControllerEvent { } @@ -70,11 +73,41 @@ public final class at/florianschuster/control/ControllerStart$Lazy : at/florians public static final field INSTANCE Lat/florianschuster/control/ControllerStart$Lazy; } -public abstract interface class at/florianschuster/control/ControllerStub { +public abstract interface class at/florianschuster/control/ControllerStub : at/florianschuster/control/Controller { public abstract fun emitState (Ljava/lang/Object;)V public abstract fun getDispatchedActions ()Ljava/util/List; } +public abstract interface class at/florianschuster/control/EffectController : at/florianschuster/control/Controller { + public abstract fun getEffects ()Lkotlinx/coroutines/flow/Flow; +} + +public final class at/florianschuster/control/EffectControllerKt { + public static final fun createEffectController (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lat/florianschuster/control/ControllerStart;Lkotlinx/coroutines/CoroutineDispatcher;)Lat/florianschuster/control/EffectController; + public static synthetic fun createEffectController$default (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lat/florianschuster/control/ControllerStart;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/EffectController; +} + +public abstract interface class at/florianschuster/control/EffectControllerStub : at/florianschuster/control/ControllerStub { + public abstract fun emitEffect (Ljava/lang/Object;)V +} + +public abstract interface class at/florianschuster/control/EffectEmitter { + public abstract fun offerEffect (Ljava/lang/Object;)V +} + +public abstract interface class at/florianschuster/control/EffectMutatorContext : at/florianschuster/control/EffectEmitter, at/florianschuster/control/MutatorContext { +} + +public abstract interface class at/florianschuster/control/EffectProvider { + public abstract fun getEffects ()Lkotlinx/coroutines/flow/Flow; +} + +public abstract interface class at/florianschuster/control/EffectReducerContext : at/florianschuster/control/EffectEmitter, at/florianschuster/control/ReducerContext { +} + +public abstract interface class at/florianschuster/control/EffectTransformerContext : at/florianschuster/control/EffectEmitter, at/florianschuster/control/TransformerContext { +} + public final class at/florianschuster/control/ExtensionsKt { public static final fun bind (Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function1;)Lkotlinx/coroutines/flow/Flow; public static final fun distinctMap (Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function1;)Lkotlinx/coroutines/flow/Flow; @@ -96,7 +129,11 @@ public abstract interface class at/florianschuster/control/ReducerContext { } public final class at/florianschuster/control/StubKt { - public static final fun stub (Lat/florianschuster/control/Controller;)Lat/florianschuster/control/ControllerStub; + public static final fun toStub (Lat/florianschuster/control/Controller;)Lat/florianschuster/control/ControllerStub; + public static final fun toStub (Lat/florianschuster/control/EffectController;)Lat/florianschuster/control/EffectControllerStub; +} + +public abstract interface class at/florianschuster/control/StubbedController : at/florianschuster/control/Controller, at/florianschuster/control/ControllerStub { } public abstract interface class at/florianschuster/control/TransformerContext { diff --git a/control-core/build.gradle.kts b/control-core/build.gradle.kts index 698816e..b7aeec2 100644 --- a/control-core/build.gradle.kts +++ b/control-core/build.gradle.kts @@ -44,10 +44,7 @@ pitest { // inlined invokeSuspend "at.florianschuster.control.ControllerImplementation\$stateJob\$1", - "at.florianschuster.control.ControllerImplementation\$stateJob\$1\$2", - - // lateinit var isInitialized - "at.florianschuster.control.ControllerImplementation\$stubInitialized\$1" + "at.florianschuster.control.ControllerImplementation\$stateJob\$1\$2" ) threads.set(4) jvmArgs.add("-ea") diff --git a/control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt b/control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt index f4e717c..d4c5492 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt @@ -9,40 +9,25 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow /** - * An [EffectProvider] provides a [Flow] of (side-)effects that can happen during a - * mutation in [Mutator] or a state reduction in [Reducer]. + * A [Controller] that provides a [Flow] of [Effect]'s that can happen during a + * mutation in [EffectMutator], a state reduction in [EffectReducer] or a + * transformation in [EffectTransformer]. + * + * An [Effect] could be a one-of UI notification such as a Toast or a Snackbar + * on Android. + * + * Before using this, make sure to look into the [Controller] documentation. */ -interface EffectProvider { +interface EffectController : Controller { /** * [Flow] of [Effect]s. Use this to collect [Effect] emissions. - * The [Flow] is received in a fan-out fashion: one emission is just collected once. + * The [Flow] is received in a fan-out fashion. One emission will be + * emitted to one collector only. */ val effects: Flow } -/** - * An [EffectEmitter] emits side-effects. - * - * This is implemented by the respective context's of [EffectMutator], [EffectReducer] - * and [EffectTransformer]. - */ -interface EffectEmitter { - - /** - * Emits an [Effect]. - */ - fun offerEffect(effect: Effect) -} - -/** - * A [Controller] that provides a [Flow] of [Effect]'s via [EffectProvider]. - * - * Before using this, make sure to look into the [Controller] documentation. - */ -interface EffectController : Controller, - EffectProvider - /** * Creates an [EffectController] bound to the [CoroutineScope] via [ControllerImplementation]. * @@ -108,6 +93,20 @@ fun CoroutineScope.createEffectController( tag = tag, controllerLog = controllerLog ) +/** + * An [EffectEmitter] can emit side-effects. + * + * This is implemented by the respective context's of [EffectMutator], [EffectReducer] + * and [EffectTransformer]. + */ +interface EffectEmitter { + + /** + * Emits an [Effect]. + */ + fun offerEffect(effect: Effect) +} + /** * A [Mutator] used in a [EffectController] that is able to emit effects. */ diff --git a/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt b/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt index c9e1d7a..402fc00 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -89,3 +90,10 @@ fun Flow.takeUntil(other: Flow): Flow = flow { } private class TakeUntilException : CancellationException() + +/** + * Regular filterNotNull requires T : Any? + */ +internal fun Flow.filterNotNullCast(): Flow { + return filter { it != null }.map { checkNotNull(it) { "oh shi-" } } +} \ No newline at end of file diff --git a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt index c1fa649..8b216a4 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt @@ -44,7 +44,7 @@ internal class ControllerImplementation( val tag: String, val controllerLog: ControllerLog -) : EffectController { +) : EffectController, EffectControllerStub { // region state machine @@ -114,36 +114,29 @@ internal class ControllerImplementation( private val effectsChannel = Channel(BUFFERED) override val effects: Flow - get() = if (stubInitialized) stub.effectChannel.asFlow() else { + get() = if (stubEnabled) stubbedEffectFlow else { effectsChannel.receiveAsFlow().cancellable() } // endregion - // region stub - - internal lateinit var stub: ControllerStubImplementation - internal val stubInitialized: Boolean get() = this::stub.isInitialized - - // endregion - // region controller override val state: Flow - get() = if (stubInitialized) stub.stateFlow else { + get() = if (stubEnabled) stubbedStateFlow else { if (controllerStart is ControllerStart.Lazy) start() mutableStateFlow } override val currentState: State - get() = if (stubInitialized) stub.stateFlow.value else { + get() = if (stubEnabled) stubbedStateFlow.value else { if (controllerStart is ControllerStart.Lazy) start() mutableStateFlow.value } override fun dispatch(action: Action) { - if (stubInitialized) { - stub.mutableDispatchedActions.add(action) + if (stubEnabled) { + stubActions.add(action) } else { if (controllerStart is ControllerStart.Lazy) start() actionChannel.offer(action) @@ -164,6 +157,28 @@ internal class ControllerImplementation( // endregion + // region stub + + internal var stubEnabled = false + + private val stubActions = mutableListOf() + private val stubbedStateFlow = MutableStateFlow(initialState) + private val _stubbedEffectFlow = MutableStateFlow(null) + private val stubbedEffectFlow = _stubbedEffectFlow.filterNotNullCast() + + override val dispatchedActions: List + get() = stubActions + + override fun emitState(state: State) { + stubbedStateFlow.value = state + } + + override fun emitEffect(effect: Effect) { + _stubbedEffectFlow.value = effect + } + + // endregion + init { controllerLog.log { ControllerEvent.Created(tag, controllerStart.logName) } if (controllerStart is ControllerStart.Immediately) { @@ -195,4 +210,4 @@ internal class ControllerImplementation( override fun offerEffect(effect: Effect) = emitter(effect) } } -} +} \ No newline at end of file diff --git a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt index 668ca6e..2fa8ab0 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt @@ -2,35 +2,12 @@ package at.florianschuster.control import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.BroadcastChannel -import kotlinx.coroutines.channels.Channel.Factory.BUFFERED -import kotlinx.coroutines.flow.MutableStateFlow import org.jetbrains.annotations.TestOnly -/** - * Retrieves a [ControllerStub] for this [Controller] used for view testing. - * Once accessed, the [Controller] is stubbed and cannot be un-stubbed. - * - * Custom implementations of [Controller] cannot be stubbed. - */ -@ExperimentalCoroutinesApi -@FlowPreview -@TestOnly -fun Controller.stub(): ControllerStub { - require(this is ControllerImplementation) { - "Cannot stub a custom implementation of a Controller." - } - if (!stubInitialized) { - controllerLog.log { ControllerEvent.Stub(tag) } - stub = ControllerStubImplementation(initialState) - } - return stub -} - /** * A stub of a [Controller] for view testing. */ -interface ControllerStub { +interface ControllerStub : Controller { /** * The [Action]'s dispatched to the [Controller] as ordered [List]. @@ -46,23 +23,23 @@ interface ControllerStub { } /** - * Retrieves a [ControllerStub] for this [Controller] used for view testing. - * Once accessed, the [Controller] is stubbed and cannot be un-stubbed. + * Converts an [Controller] to an [ControllerStub] for view testing. + * Once converted, the [Controller] is stubbed and cannot be un-stubbed. * * Custom implementations of [Controller] cannot be stubbed. */ @ExperimentalCoroutinesApi @FlowPreview @TestOnly -fun EffectController.effectStub(): EffectControllerStub { - require(this is ControllerImplementation) { +fun Controller.toStub(): ControllerStub { + require(this is ControllerImplementation) { "Cannot stub a custom implementation of a Controller." } - if (!stubInitialized) { + if (!stubEnabled) { controllerLog.log { ControllerEvent.Stub(tag) } - stub = ControllerStubImplementation(initialState) + stubEnabled = true } - return stub + return this } /** @@ -78,24 +55,21 @@ interface EffectControllerStub : ControllerStub( - initialState: State -) : EffectControllerStub { - - internal val mutableDispatchedActions = mutableListOf() - internal val stateFlow = MutableStateFlow(initialState) - internal val effectChannel = BroadcastChannel(BUFFERED) - - override val dispatchedActions: List get() = mutableDispatchedActions - - override fun emitState(state: State) { - stateFlow.value = state +@FlowPreview +@TestOnly +fun EffectController.toStub(): EffectControllerStub { + require(this is ControllerImplementation) { + "Cannot stub a custom implementation of a EffectController." } - - override fun emitEffect(effect: Effect) { - effectChannel.offer(effect) + if (!stubEnabled) { + controllerLog.log { ControllerEvent.Stub(tag) } + stubEnabled = true } + return this } diff --git a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt index 1c0e188..88db1d5 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt @@ -49,7 +49,7 @@ internal class EventTest { assertTrue(lastEvents[3] is ControllerEvent.State) } - sut.stub() + sut.toStub() assertTrue(events.last() is ControllerEvent.Stub) sut.cancel() diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt index bb88110..1231ddb 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList @@ -60,4 +61,16 @@ internal class ExtensionsTest { val emptyResult = emptyFlow().takeUntil(flow { delay(1101); emit(Unit) }).toList() assertEquals(emptyList(), emptyResult) } + + @Test + fun `filterNotNullCast with empty flow`() = runBlockingTest { + val result = emptyFlow().filterNotNullCast().toList() + assertEquals(emptyList(), result) + } + + @Test + fun `filterNotNullCast with non-empty flow`() = runBlockingTest { + val result = flowOf(null, 1, 2, null).filterNotNullCast().toList() + assertEquals(listOf(1, 2), result) + } } \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt index 5249bf6..3feb04e 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt @@ -6,6 +6,7 @@ import at.florianschuster.test.flow.emissionCount import at.florianschuster.test.flow.emissions import at.florianschuster.test.flow.expect import at.florianschuster.test.flow.lastEmission +import at.florianschuster.test.flow.noEmissions import at.florianschuster.test.flow.testIn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -14,8 +15,10 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.test.TestCoroutineScope import org.junit.Rule import org.junit.Test @@ -184,32 +187,33 @@ internal class ImplementationTest { fun `MutatorContext is built correctly`() { val stateAccessor = { 1 } val actions = flowOf(1) - val effectEmitter: (Int) -> Unit = { _ -> } - val sut = ControllerImplementation.createMutatorContext( + var emittedEffect: Int? = null + val sut = ControllerImplementation.createMutatorContext( stateAccessor, - actions, - effectEmitter - ) + actions + ) { emittedEffect = it } + + sut.offerEffect(1) assertEquals(stateAccessor(), sut.currentState) assertEquals(actions, sut.actions) - assertEquals(effectEmitter, sut::offerEffect) + assertEquals(1, emittedEffect) } @Test fun `ReducerContext is built correctly`() { - val effectEmitter: (Int) -> Unit = { _ -> } - val sut = ControllerImplementation.createReducerContext(effectEmitter) - - assertEquals(effectEmitter, sut::offerEffect) + var emittedEffect: Int? = null + val sut = ControllerImplementation.createReducerContext { emittedEffect = it } + sut.offerEffect(2) + assertEquals(2, emittedEffect) } @Test fun `TransformerContext is built correctly`() { - val effectEmitter: (Int) -> Unit = { _ -> } - val sut = ControllerImplementation.createTransformerContext(effectEmitter) - - assertEquals(effectEmitter, sut::offerEffect) + var emittedEffect: Int? = null + val sut = ControllerImplementation.createTransformerContext { emittedEffect = it } + sut.offerEffect(3) + assertEquals(3, emittedEffect) } @Test @@ -228,6 +232,48 @@ internal class ImplementationTest { states expect lastEmission(1) } + @Test + fun `effects are received from mutator, reducer and transformer`() { + val sut = testCoroutineScope.createEffectController() + val states = sut.state.testIn(testCoroutineScope) + val effects = sut.effects.testIn(testCoroutineScope) + + val testEmissions = listOf( + TestEffect.Reducer, + TestEffect.ActionTransformer, + TestEffect.MutationTransformer, + TestEffect.Mutator, + TestEffect.StateTransformer + ) + + testEmissions.map(TestEffect::ordinal).forEach(sut::dispatch) + + states expect emissions(listOf(0) + testEmissions.map(TestEffect::ordinal)) + effects expect emissions(testEmissions) + } + + @Test + fun `effects are only received once collector`() { + val sut = testCoroutineScope.createEffectController() + val effects = mutableListOf() + sut.effects.onEach { effects.add(it) }.launchIn(testCoroutineScope) + sut.effects.onEach { effects.add(it) }.launchIn(testCoroutineScope) + + val testEmissions = listOf( + TestEffect.Reducer, + TestEffect.ActionTransformer, + TestEffect.MutationTransformer, + TestEffect.Reducer, + TestEffect.Mutator, + TestEffect.StateTransformer, + TestEffect.Reducer + ) + + testEmissions.map(TestEffect::ordinal).forEach(sut::dispatch) + + assertEquals(testEmissions, effects) + } + private fun CoroutineScope.createAlwaysSameStateController() = ControllerImplementation( scope = this, @@ -349,4 +395,47 @@ internal class ImplementationTest { tag = "ImplementationTest.GlobalStateMergeController", controllerLog = ControllerLog.None ) + + enum class TestEffect { + Mutator, Reducer, ActionTransformer, MutationTransformer, StateTransformer + } + + private fun CoroutineScope.createEffectController() = + ControllerImplementation( + scope = this, + dispatcher = defaultScopeDispatcher(), + controllerStart = ControllerStart.Lazy, + initialState = 0, + mutator = { action -> + if (action == TestEffect.Mutator.ordinal) offerEffect(TestEffect.Mutator) + flowOf(action) + }, + reducer = { mutation, _ -> + if (mutation == TestEffect.Reducer.ordinal) offerEffect(TestEffect.Reducer) + mutation + }, + actionsTransformer = { actions -> + actions.onEach { + if (it == TestEffect.ActionTransformer.ordinal) { + offerEffect(TestEffect.ActionTransformer) + } + } + }, + mutationsTransformer = { mutations -> + mutations.onEach { + if (it == TestEffect.MutationTransformer.ordinal) { + offerEffect(TestEffect.MutationTransformer) + } + } + }, + statesTransformer = { states -> + states.onEach { + if (it == TestEffect.StateTransformer.ordinal) { + offerEffect(TestEffect.StateTransformer) + } + } + }, + tag = "ImplementationTest.EffectController", + controllerLog = ControllerLog.None + ) } \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt index 84e2947..0444a34 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt @@ -28,19 +28,16 @@ internal class StubTest { override val state: Flow get() = error("") } - assertFailsWith { sut.stub() } + assertFailsWith { sut.toStub() } } @Test - fun `stub is initialized only after accessing stub()`() { + fun `stub is enabled only after conversion()`() { val sut = testCoroutineScope.createStringController() - assertFalse(sut.stubInitialized) + assertFalse(sut.stubEnabled) - assertFailsWith { sut.stub.dispatchedActions } - assertFalse(sut.stubInitialized) - - sut.stub().dispatchedActions - assertTrue(sut.stubInitialized) + sut.toStub().dispatchedActions + assertTrue(sut.stubEnabled) } @Test @@ -50,11 +47,11 @@ internal class StubTest { listOf("two"), listOf("three") ) - val sut = testCoroutineScope.createStringController().apply { stub() } + val sut = testCoroutineScope.createStringController().apply { toStub() } expectedActions.forEach(sut::dispatch) - assertEquals(expectedActions, sut.stub().dispatchedActions) + assertEquals(expectedActions, sut.toStub().dispatchedActions) } @Test @@ -64,10 +61,10 @@ internal class StubTest { listOf("two"), listOf("three") ) - val sut = testCoroutineScope.createStringController().apply { stub() } + val sut = testCoroutineScope.createStringController().apply { toStub() } val testFlow = sut.state.testIn(testCoroutineScope) - expectedStates.forEach(sut.stub()::emitState) + expectedStates.forEach(sut.toStub()::emitState) testFlow expect emissions(listOf(initialState) + expectedStates) } @@ -79,21 +76,21 @@ internal class StubTest { listOf("two"), listOf("three") ) - val sut = testCoroutineScope.createStringController().apply { stub() } + val sut = testCoroutineScope.createStringController().apply { toStub() } - sut.stub().emitState(listOf("something 1")) - sut.stub().emitState(listOf("something 2")) + sut.toStub().emitState(listOf("something 1")) + sut.toStub().emitState(listOf("something 2")) val testFlow = sut.state.testIn(testCoroutineScope) - expectedStates.forEach(sut.stub()::emitState) + expectedStates.forEach(sut.toStub()::emitState) testFlow expect emissions(listOf(listOf("something 2")) + expectedStates) } @Test fun `stub action does not trigger state machine`() { - val sut = testCoroutineScope.createStringController().apply { stub() } + val sut = testCoroutineScope.createStringController().apply { toStub() } sut.dispatch(listOf("test")) diff --git a/examples/android-counter/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterViewTest.kt b/examples/android-counter/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterViewTest.kt index a008dbf..21b4e40 100644 --- a/examples/android-counter/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterViewTest.kt +++ b/examples/android-counter/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterViewTest.kt @@ -12,7 +12,7 @@ import at.florianschuster.control.ControllerStub import at.florianschuster.control.kotlincounter.CounterAction import at.florianschuster.control.kotlincounter.CounterState import at.florianschuster.control.kotlincounter.createCounterController -import at.florianschuster.control.stub +import at.florianschuster.control.toStub import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -26,9 +26,8 @@ internal class CounterViewTest { @Before fun setup() { CounterView.CounterControllerProvider = { scope -> - val controller = scope.createCounterController() - stub = controller.stub() - controller + stub = scope.createCounterController().toStub() + stub } launchFragmentInContainer() } diff --git a/examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/GithubViewTest.kt b/examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/GithubViewTest.kt index eab68bb..089ea16 100644 --- a/examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/GithubViewTest.kt +++ b/examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/GithubViewTest.kt @@ -14,7 +14,7 @@ import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.runners.AndroidJUnit4 import at.florianschuster.control.androidgithub.R -import at.florianschuster.control.stub +import at.florianschuster.control.toStub import org.hamcrest.Matcher import org.hamcrest.Matchers.not import org.junit.Before @@ -32,8 +32,7 @@ internal class GithubViewTest { GithubView.GithubViewModelFactory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - viewModel = GithubViewModel().apply { controller.stub() } - return viewModel as T + return GithubViewModel().apply { controller.toStub() } as T } } launchFragmentInContainer(themeResId = R.style.Theme_MaterialComponents) @@ -51,20 +50,20 @@ internal class GithubViewTest { // then assertEquals( GithubViewModel.Action.UpdateQuery(testQuery), - viewModel.controller.stub().dispatchedActions.last() + viewModel.controller.toStub().dispatchedActions.last() ) } @Test fun whenStateOffersLoadingNextPageThenProgressBarIsShown() { // when - viewModel.controller.stub().emitState(GithubViewModel.State(loadingNextPage = true)) + viewModel.controller.toStub().emitState(GithubViewModel.State(loadingNextPage = true)) // then onView(withId(R.id.loadingProgressBar)).check(matches(isDisplayed())) // when - viewModel.controller.stub().emitState(GithubViewModel.State(loadingNextPage = false)) + viewModel.controller.toStub().emitState(GithubViewModel.State(loadingNextPage = false)) // then onView(withId(R.id.loadingProgressBar)).check(matches(not(isDisplayed()))) diff --git a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/GithubView.kt b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/GithubView.kt index f5b026c..9b26b6e 100644 --- a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/GithubView.kt +++ b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/GithubView.kt @@ -3,7 +3,6 @@ package at.florianschuster.control.androidgithub.search import android.content.Intent import android.os.Bundle import android.view.View -import android.widget.EditText import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels From e7e726e0edc6c68c39b013dd8cacbec2e77d7ed9 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sat, 29 Aug 2020 13:57:34 +0200 Subject: [PATCH 03/12] Update api dump --- control-core/api/control-core.api | 7 ------- 1 file changed, 7 deletions(-) diff --git a/control-core/api/control-core.api b/control-core/api/control-core.api index 6561509..ff22ff7 100644 --- a/control-core/api/control-core.api +++ b/control-core/api/control-core.api @@ -98,10 +98,6 @@ public abstract interface class at/florianschuster/control/EffectEmitter { public abstract interface class at/florianschuster/control/EffectMutatorContext : at/florianschuster/control/EffectEmitter, at/florianschuster/control/MutatorContext { } -public abstract interface class at/florianschuster/control/EffectProvider { - public abstract fun getEffects ()Lkotlinx/coroutines/flow/Flow; -} - public abstract interface class at/florianschuster/control/EffectReducerContext : at/florianschuster/control/EffectEmitter, at/florianschuster/control/ReducerContext { } @@ -133,9 +129,6 @@ public final class at/florianschuster/control/StubKt { public static final fun toStub (Lat/florianschuster/control/EffectController;)Lat/florianschuster/control/EffectControllerStub; } -public abstract interface class at/florianschuster/control/StubbedController : at/florianschuster/control/Controller, at/florianschuster/control/ControllerStub { -} - public abstract interface class at/florianschuster/control/TransformerContext { } From d6828b4e3015e67db3d9821e585259b652ce43be Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sat, 29 Aug 2020 14:02:17 +0200 Subject: [PATCH 04/12] Remove unused imports --- .../test/kotlin/at/florianschuster/control/ImplementationTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt index 3feb04e..6ce48b1 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt @@ -6,7 +6,6 @@ import at.florianschuster.test.flow.emissionCount import at.florianschuster.test.flow.emissions import at.florianschuster.test.flow.expect import at.florianschuster.test.flow.lastEmission -import at.florianschuster.test.flow.noEmissions import at.florianschuster.test.flow.testIn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay From ce449e08107f62d38cfc10d8388bef0da5d5c818 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sat, 29 Aug 2020 20:20:36 +0200 Subject: [PATCH 05/12] Add more tests --- .../control/defaultDispatcher.kt | 8 ++-- .../florianschuster/control/implementation.kt | 19 +++++----- .../at/florianschuster/control/EventTest.kt | 37 +++++++++++++++++- .../control/ImplementationTest.kt | 16 ++++++++ .../at/florianschuster/control/StubTest.kt | 38 +++++++++++++++++-- 5 files changed, 101 insertions(+), 17 deletions(-) diff --git a/control-core/src/main/kotlin/at/florianschuster/control/defaultDispatcher.kt b/control-core/src/main/kotlin/at/florianschuster/control/defaultDispatcher.kt index ee1c538..3aacc99 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/defaultDispatcher.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/defaultDispatcher.kt @@ -8,7 +8,9 @@ import kotlin.coroutines.ContinuationInterceptor * Helper to fetch the [CoroutineDispatcher] in the [CoroutineScope]. */ internal fun CoroutineScope.defaultScopeDispatcher(): CoroutineDispatcher { - val interceptor = coroutineContext[ContinuationInterceptor] - checkNotNull(interceptor) { "CoroutineScope does not have an interceptor" } - return interceptor as CoroutineDispatcher + val continuationInterceptor = coroutineContext[ContinuationInterceptor] + checkNotNull(continuationInterceptor) { + "CoroutineScope does not have a ContinuationInterceptor" + } + return continuationInterceptor as CoroutineDispatcher } diff --git a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt index 8b216a4..7199a30 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt @@ -103,16 +103,15 @@ internal class ControllerImplementation( // region effects private val effectEmitter: (Effect) -> Unit = { effect -> - controllerLog.log { ControllerEvent.Effect(tag, effect.toString()) } val canBeOffered = effectsChannel.offer(effect) - if (!canBeOffered) { - val error = ControllerError.Effect(tag, effect.toString()) - controllerLog.log { ControllerEvent.Error(tag, error) } - throw error + if (canBeOffered) { + controllerLog.log { ControllerEvent.Effect(tag, effect.toString()) } + } else { + throw ControllerError.Effect(tag, effect.toString()) } } - private val effectsChannel = Channel(BUFFERED) + private val effectsChannel = Channel(EFFECTS_CAPACITY) override val effects: Flow get() = if (stubEnabled) stubbedEffectFlow else { effectsChannel.receiveAsFlow().cancellable() @@ -136,7 +135,7 @@ internal class ControllerImplementation( override fun dispatch(action: Action) { if (stubEnabled) { - stubActions.add(action) + stubbedActions.add(action) } else { if (controllerStart is ControllerStart.Lazy) start() actionChannel.offer(action) @@ -161,13 +160,13 @@ internal class ControllerImplementation( internal var stubEnabled = false - private val stubActions = mutableListOf() + private val stubbedActions = mutableListOf() private val stubbedStateFlow = MutableStateFlow(initialState) private val _stubbedEffectFlow = MutableStateFlow(null) private val stubbedEffectFlow = _stubbedEffectFlow.filterNotNullCast() override val dispatchedActions: List - get() = stubActions + get() = stubbedActions override fun emitState(state: State) { stubbedStateFlow.value = state @@ -187,6 +186,8 @@ internal class ControllerImplementation( } companion object { + internal const val EFFECTS_CAPACITY = 64 + internal fun createMutatorContext( stateAccessor: () -> State, actionFlow: Flow, diff --git a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt index 88db1d5..9427b94 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt @@ -49,11 +49,30 @@ internal class EventTest { assertTrue(lastEvents[3] is ControllerEvent.State) } + sut.cancel() + assertTrue(events.last() is ControllerEvent.Completed) + } + + @Test + fun `ControllerStub logs event correctly`() { + val events = mutableListOf() + val sut: Controller = TestCoroutineScope().eventsController( + events, + controllerStart = ControllerStart.Manual + ) sut.toStub() assertTrue(events.last() is ControllerEvent.Stub) + } - sut.cancel() - assertTrue(events.last() is ControllerEvent.Completed) + @Test + fun `EffectControllerStub logs event correctly`() { + val events = mutableListOf() + val sut: EffectController = TestCoroutineScope().eventsController( + events, + controllerStart = ControllerStart.Manual + ) + sut.toStub() + assertTrue(events.last() is ControllerEvent.Stub) } @Test @@ -80,6 +99,20 @@ internal class EventTest { } } + @Test + fun `ControllerImplementation logs effect error correctly`() { + val events = mutableListOf() + val sut = TestCoroutineScope().eventsController(events) + + repeat(ControllerImplementation.EFFECTS_CAPACITY) { sut.dispatch(effectValue) } + sut.dispatch(effectValue) + + events.takeLast(2).let { lastEvents -> + assertTrue(lastEvents[0] is ControllerEvent.Error) + assertTrue(lastEvents[1] is ControllerEvent.Completed) + } + } + private fun CoroutineScope.eventsController( events: MutableList, controllerStart: ControllerStart = ControllerStart.Lazy diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt index 6ce48b1..577ec49 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.test.TestCoroutineScope import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertTrue internal class ImplementationTest { @@ -273,6 +274,21 @@ internal class ImplementationTest { assertEquals(testEmissions, effects) } + @Test + fun `effects overflow throws error`() { + val scope = TestCoroutineScope() + val sut = scope.createEffectController() + + repeat(ControllerImplementation.EFFECTS_CAPACITY) { sut.dispatch(1) } + assertTrue(scope.uncaughtExceptions.isEmpty()) + + sut.dispatch(1) + + assertEquals(1, scope.uncaughtExceptions.size) + val error = scope.uncaughtExceptions.first() + assertEquals(ControllerError.Effect::class, assertNotNull(error.cause)::class) + } + private fun CoroutineScope.createAlwaysSameStateController() = ControllerImplementation( scope = this, diff --git a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt index 0444a34..5373eea 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt @@ -32,11 +32,32 @@ internal class StubTest { } @Test - fun `stub is enabled only after conversion()`() { + fun `custom EffectController implementation cannot be stubbed`() { + val sut = object : EffectController { + override fun dispatch(action: Int) = Unit + override val currentState: Int get() = error("") + override val state: Flow get() = error("") + override val effects: Flow get() = error("") + } + + assertFailsWith { sut.toStub() } + } + + @Test + fun `Controller stub is enabled only after conversion()`() { + val sut = testCoroutineScope.createStringController() + assertFalse(sut.stubEnabled) + + (sut as Controller, List>).toStub() + assertTrue(sut.stubEnabled) + } + + @Test + fun `EffectController stub is enabled only after conversion()`() { val sut = testCoroutineScope.createStringController() assertFalse(sut.stubEnabled) - sut.toStub().dispatchedActions + (sut as EffectController, List, Nothing>).toStub() assertTrue(sut.stubEnabled) } @@ -97,8 +118,19 @@ internal class StubTest { assertEquals(initialState, sut.currentState) } + @Test + fun `stub emits effects`() { + val sut = testCoroutineScope.createStringController().apply { toStub() } + val testFlow = sut.effects.testIn(testCoroutineScope) + + sut.emitEffect("effect1") + sut.emitEffect("effect2") + + testFlow expect emissions("effect1", "effect2") + } + private fun CoroutineScope.createStringController() = - ControllerImplementation, List, List, Nothing>( + ControllerImplementation, List, List, String>( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = ControllerStart.Lazy, From 957bb160e554199d7eb6f246c247ce4f9e2ae5f2 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sun, 30 Aug 2020 22:20:31 +0200 Subject: [PATCH 06/12] Update Github example with effects --- buildSrc/src/main/kotlin/Libs.kt | 5 + buildSrc/src/main/kotlin/Versions.kt | 14 +-- .../control/androidcounter/CounterViewTest.kt | 8 +- .../control/androidcounter/CounterView.kt | 8 +- examples/android-github/build.gradle.kts | 1 + .../{GithubViewTest.kt => SearchViewTest.kt} | 41 +++++--- .../control/androidgithub/GithubApi.kt | 19 +--- .../control/androidgithub/MainActivity.kt | 4 +- .../control/androidgithub/model/Repository.kt | 20 ++++ .../androidgithub/search/RepoAdapter.kt | 45 --------- .../androidgithub/search/SearchAdapter.kt | 66 +++++++++++++ .../search/{GithubView.kt => SearchView.kt} | 58 +++++------ ...{GithubViewModel.kt => SearchViewModel.kt} | 28 ++++-- .../control/androidgithub/snackbar.kt | 22 +++++ .../control/androidgithub/viewbinding.kt | 36 +++++++ .../src/main/res/layout/item_repo.xml | 57 ++++++++--- .../{view_github.xml => view_search.xml} | 3 - .../src/main/res/values/dimens.xml | 5 + .../src/main/res/values/strings.xml | 3 + .../src/main/res/values/styles.xml | 1 - ...iewModelTest.kt => SearchViewModelTest.kt} | 96 +++++++++++-------- gradle/wrapper/gradle-wrapper.properties | 2 +- 22 files changed, 354 insertions(+), 188 deletions(-) rename examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/{GithubViewTest.kt => SearchViewTest.kt} (59%) create mode 100644 examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/model/Repository.kt delete mode 100644 examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/RepoAdapter.kt create mode 100644 examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchAdapter.kt rename examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/{GithubView.kt => SearchView.kt} (59%) rename examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/{GithubViewModel.kt => SearchViewModel.kt} (81%) create mode 100644 examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/snackbar.kt create mode 100644 examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/viewbinding.kt rename examples/android-github/src/main/res/layout/{view_github.xml => view_search.xml} (97%) create mode 100644 examples/android-github/src/main/res/values/dimens.xml rename examples/android-github/src/test/kotlin/at/florianschuster/control/androidgithub/search/{GithubViewModelTest.kt => SearchViewModelTest.kt} (53%) diff --git a/buildSrc/src/main/kotlin/Libs.kt b/buildSrc/src/main/kotlin/Libs.kt index aa7a1b2..78650f0 100644 --- a/buildSrc/src/main/kotlin/Libs.kt +++ b/buildSrc/src/main/kotlin/Libs.kt @@ -219,4 +219,9 @@ object Libs { * http://mockk.io */ const val mockk: String = "io.mockk:mockk:" + Versions.mockk + + /** + * https://github.com/coil-kt/coil + */ + const val coil: String = "io.coil-kt:coil:" + Versions.coil } diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 52e7b85..0f24564 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -22,7 +22,7 @@ object Versions { const val androidx_fragment: String = "1.2.5" - const val androidx_test: String = "1.2.0" + const val androidx_test: String = "1.3.0" const val io_ktor: String = "1.4.0" @@ -44,9 +44,9 @@ object Versions { const val gradle_pitest_plugin: String = "1.5.2" - const val constraintlayout: String = "2.0.0" + const val constraintlayout: String = "2.0.1" - const val espresso_core: String = "3.2.0" + const val espresso_core: String = "3.3.0" const val atomicfu_jvm: String = "0.14.4" @@ -56,7 +56,7 @@ object Versions { const val appcompat: String = "1.2.0" - const val junit_ktx: String = "1.1.1" + const val junit_ktx: String = "1.1.2" const val material: String = "1.2.0" @@ -66,12 +66,14 @@ object Versions { const val mockk: String = "1.10.0" + const val coil: String = "0.12.0" + /** - * Current version: "6.6" + * Current version: "6.6.1" * See issue 19: How to update Gradle itself? * https://github.com/jmfayard/buildSrcVersions/issues/19 */ - const val gradleLatestVersion: String = "6.6" + const val gradleLatestVersion: String = "6.6.1" } /** diff --git a/examples/android-counter/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterViewTest.kt b/examples/android-counter/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterViewTest.kt index 21b4e40..8985043 100644 --- a/examples/android-counter/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterViewTest.kt +++ b/examples/android-counter/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterViewTest.kt @@ -7,7 +7,6 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText -import androidx.test.ext.junit.runners.AndroidJUnit4 import at.florianschuster.control.ControllerStub import at.florianschuster.control.kotlincounter.CounterAction import at.florianschuster.control.kotlincounter.CounterState @@ -15,19 +14,16 @@ import at.florianschuster.control.kotlincounter.createCounterController import at.florianschuster.control.toStub import org.junit.Before import org.junit.Test -import org.junit.runner.RunWith import kotlin.test.assertEquals -@RunWith(AndroidJUnit4::class) internal class CounterViewTest { private lateinit var stub: ControllerStub @Before fun setup() { - CounterView.CounterControllerProvider = { scope -> - stub = scope.createCounterController().toStub() - stub + CounterView.ControllerFactory = { scope -> + scope.createCounterController().toStub().also { stub = it } } launchFragmentInContainer() } diff --git a/examples/android-counter/src/main/kotlin/at/florianschuster/control/androidcounter/CounterView.kt b/examples/android-counter/src/main/kotlin/at/florianschuster/control/androidcounter/CounterView.kt index faacdbb..1c87320 100644 --- a/examples/android-counter/src/main/kotlin/at/florianschuster/control/androidcounter/CounterView.kt +++ b/examples/android-counter/src/main/kotlin/at/florianschuster/control/androidcounter/CounterView.kt @@ -24,7 +24,7 @@ internal class CounterView : Fragment(R.layout.view_counter) { super.onViewCreated(view, savedInstanceState) binding = ViewCounterBinding.bind(view) - val controller = CounterControllerProvider(viewLifecycleOwner.lifecycleScope) + val controller = ControllerFactory(viewLifecycleOwner.lifecycleScope) // action requireBinding.increaseButton.clicks() @@ -57,8 +57,8 @@ internal class CounterView : Fragment(R.layout.view_counter) { } companion object { - internal var CounterControllerProvider: ( - scope: CoroutineScope - ) -> CounterController = { it.createCounterController() } + internal var ControllerFactory: (scope: CoroutineScope) -> CounterController = { scope -> + scope.createCounterController() + } } } diff --git a/examples/android-github/build.gradle.kts b/examples/android-github/build.gradle.kts index 29baf5d..b923736 100644 --- a/examples/android-github/build.gradle.kts +++ b/examples/android-github/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation(project(":control-core")) implementation(Libs.appcompat) + implementation(Libs.coil) implementation(Libs.constraintlayout) implementation(Libs.flowbinding_android) implementation(Libs.flowbinding_core) diff --git a/examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/GithubViewTest.kt b/examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/SearchViewTest.kt similarity index 59% rename from examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/GithubViewTest.kt rename to examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/SearchViewTest.kt index 089ea16..df54035 100644 --- a/examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/GithubViewTest.kt +++ b/examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/SearchViewTest.kt @@ -12,62 +12,73 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.espresso.matcher.ViewMatchers.withText +import at.florianschuster.control.EffectControllerStub import at.florianschuster.control.androidgithub.R import at.florianschuster.control.toStub import org.hamcrest.Matcher import org.hamcrest.Matchers.not import org.junit.Before import org.junit.Test -import org.junit.runner.RunWith import kotlin.test.assertEquals -@RunWith(AndroidJUnit4::class) -internal class GithubViewTest { +internal class SearchViewTest { - private lateinit var viewModel: GithubViewModel + private lateinit var stub: EffectControllerStub @Before fun setup() { - GithubView.GithubViewModelFactory = object : ViewModelProvider.Factory { + SearchViewModel.Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - return GithubViewModel().apply { controller.toStub() } as T + val viewModel = SearchViewModel() + stub = viewModel.controller.toStub() + return viewModel as T } } - launchFragmentInContainer(themeResId = R.style.Theme_MaterialComponents) + launchFragmentInContainer(themeResId = R.style.Theme_MaterialComponents) } @Test - fun whenSearchEditTextInputThenCorrectControllerAction() { + fun whenSearchEditTextInput_ThenCorrectControllerAction() { // given val testQuery = "test" // when onView(withId(R.id.searchEditText)).perform(replaceText(testQuery)) - onView(isRoot()).perform(idleFor(GithubView.SearchDebounceMilliseconds)) + onView(isRoot()).perform(idleFor(SearchView.SearchDebounceMilliseconds)) // then assertEquals( - GithubViewModel.Action.UpdateQuery(testQuery), - viewModel.controller.toStub().dispatchedActions.last() + SearchViewModel.Action.UpdateQuery(testQuery), + stub.dispatchedActions.last() ) } @Test - fun whenStateOffersLoadingNextPageThenProgressBarIsShown() { + fun whenStateOffersLoadingNextPage_ThenProgressBarIsShown() { // when - viewModel.controller.toStub().emitState(GithubViewModel.State(loadingNextPage = true)) + stub.emitState(SearchViewModel.State(loadingNextPage = true)) // then onView(withId(R.id.loadingProgressBar)).check(matches(isDisplayed())) // when - viewModel.controller.toStub().emitState(GithubViewModel.State(loadingNextPage = false)) + stub.emitState(SearchViewModel.State(loadingNextPage = false)) // then onView(withId(R.id.loadingProgressBar)).check(matches(not(isDisplayed()))) } + + @Test + fun whenNetworkErrorEffect_ThenSnackbarIsShown() { + // when + stub.emitEffect(SearchViewModel.Effect.NetworkError) + + // then + onView(withId(com.google.android.material.R.id.snackbar_text)) + .check(matches(withText(R.string.info_network_error))) + } } @Suppress("SameParameterValue") diff --git a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/GithubApi.kt b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/GithubApi.kt index c78b0bc..0c26d0d 100644 --- a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/GithubApi.kt +++ b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/GithubApi.kt @@ -1,6 +1,6 @@ package at.florianschuster.control.androidgithub -import android.net.Uri +import at.florianschuster.control.androidgithub.model.Repository import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.features.json.JsonFeature @@ -11,7 +11,6 @@ import io.ktor.client.features.logging.Logging import io.ktor.client.features.logging.SIMPLE import io.ktor.client.request.get import io.ktor.client.request.parameter -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -30,15 +29,14 @@ internal class GithubApi( } } ) { - @Serializable - private data class Response(val items: List) + private data class SearchResponse(val items: List) suspend fun search( query: String, page: Int - ): List { - val response = httpClient.get( + ): List { + val response = httpClient.get( "https://api.github.com/search/repositories" ) { url { @@ -49,12 +47,3 @@ internal class GithubApi( return response.items } } - -@Serializable -internal data class Repo( - val id: Int, - @SerialName("full_name") val name: String, - val description: String? = null -) { - val webUri: Uri get() = Uri.parse("https://github.com/$name") -} diff --git a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/MainActivity.kt b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/MainActivity.kt index 7e29967..76183ba 100644 --- a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/MainActivity.kt +++ b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/MainActivity.kt @@ -3,7 +3,7 @@ package at.florianschuster.control.androidgithub import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.commit -import at.florianschuster.control.androidgithub.search.GithubView +import at.florianschuster.control.androidgithub.search.SearchView internal class MainActivity : AppCompatActivity() { @@ -11,7 +11,7 @@ internal class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) if (savedInstanceState == null) { supportFragmentManager.commit { - replace(android.R.id.content, GithubView()) + replace(android.R.id.content, SearchView()) } } } diff --git a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/model/Repository.kt b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/model/Repository.kt new file mode 100644 index 0000000..7a198f0 --- /dev/null +++ b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/model/Repository.kt @@ -0,0 +1,20 @@ +package at.florianschuster.control.androidgithub.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class Repository( + val id: Int, + @SerialName("full_name") val fullName: String, + val description: String? = null, + val owner: Owner, + @SerialName("updated_at") val lastUpdated: String, + @SerialName("html_url") val webUrl: String +) { + + @Serializable + data class Owner( + @SerialName("avatar_url") val avatarUrl: String + ) +} diff --git a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/RepoAdapter.kt b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/RepoAdapter.kt deleted file mode 100644 index 3225d15..0000000 --- a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/RepoAdapter.kt +++ /dev/null @@ -1,45 +0,0 @@ -package at.florianschuster.control.androidgithub.search - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import at.florianschuster.control.androidgithub.Repo -import at.florianschuster.control.androidgithub.databinding.ItemRepoBinding - -internal class RepoAdapter( - private val onItemClick: (Repo) -> Unit -) : ListAdapter( - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Repo, newItem: Repo): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: Repo, newItem: Repo): Boolean = oldItem == newItem - } -) { - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): RepoViewHolder = RepoViewHolder( - ItemRepoBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) - - override fun onBindViewHolder( - holder: RepoViewHolder, - position: Int - ): Unit = holder.bind(getItem(position), onItemClick) -} - -internal class RepoViewHolder( - private val binding: ItemRepoBinding -) : RecyclerView.ViewHolder(binding.root) { - fun bind(repo: Repo, onItemClick: (Repo) -> Unit) { - binding.root.setOnClickListener { onItemClick(repo) } - binding.repoNameTextView.text = repo.name - with(binding.repoDescriptionTextView) { - isVisible = repo.description != null - text = repo.description - } - } -} diff --git a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchAdapter.kt b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchAdapter.kt new file mode 100644 index 0000000..b384138 --- /dev/null +++ b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchAdapter.kt @@ -0,0 +1,66 @@ +package at.florianschuster.control.androidgithub.search + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import at.florianschuster.control.androidgithub.R +import at.florianschuster.control.androidgithub.databinding.ItemRepoBinding +import at.florianschuster.control.androidgithub.model.Repository +import coil.load +import coil.transform.RoundedCornersTransformation + +internal class SearchAdapter( + private val onItemClick: (Repository) -> Unit +) : ListAdapter( + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Repository, newItem: Repository): Boolean = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: Repository, newItem: Repository): Boolean = + oldItem == newItem + } +) { + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): RepoViewHolder = RepoViewHolder( + ItemRepoBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + + override fun onBindViewHolder( + holder: RepoViewHolder, + position: Int + ): Unit = holder.bind(getItem(position), onItemClick) +} + +internal class RepoViewHolder( + private val binding: ItemRepoBinding +) : RecyclerView.ViewHolder(binding.root) { + + private val resources = itemView.resources + + fun bind(repo: Repository, onItemClick: (Repository) -> Unit) { + binding.root.setOnClickListener { onItemClick(repo) } + binding.ownerIconImageView.load(repo.owner.avatarUrl) { + crossfade(true) + transformations( + RoundedCornersTransformation( + resources.getDimensionPixelSize(R.dimen.dimen_8).toFloat() + ) + ) + } + binding.repoNameTextView.text = repo.fullName + with(binding.repoDescriptionTextView) { + isVisible = repo.description != null + text = repo.description + } + binding.repoLastUpdatedTextView.text = resources.getString( + R.string.label_last_updated, + repo.lastUpdated + ) + } +} diff --git a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/GithubView.kt b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchView.kt similarity index 59% rename from examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/GithubView.kt rename to examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchView.kt index 9b26b6e..6c74aee 100644 --- a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/GithubView.kt +++ b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchView.kt @@ -1,76 +1,81 @@ package at.florianschuster.control.androidgithub.search import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.View import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import at.florianschuster.control.androidgithub.R +import at.florianschuster.control.androidgithub.databinding.ViewSearchBinding +import at.florianschuster.control.androidgithub.showSnackBar +import at.florianschuster.control.androidgithub.viewBinding import at.florianschuster.control.bind import at.florianschuster.control.distinctMap -import at.florianschuster.control.androidgithub.R -import at.florianschuster.control.androidgithub.databinding.ViewGithubBinding import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample import reactivecircus.flowbinding.android.widget.textChanges import reactivecircus.flowbinding.recyclerview.scrollEvents -internal class GithubView : Fragment(R.layout.view_github) { - - private var binding: ViewGithubBinding? = null - private val requireBinding: ViewGithubBinding get() = requireNotNull(binding) +internal class SearchView : Fragment(R.layout.view_search) { - private val viewModel: GithubViewModel by viewModels { GithubViewModelFactory } + private val binding by viewBinding(ViewSearchBinding::bind) + private val viewModel by viewModels { SearchViewModel.Factory } - private val repoAdapter = RepoAdapter { repo -> - startActivity(Intent(Intent.ACTION_VIEW, repo.webUri)) + private val repoAdapter = SearchAdapter { repo -> + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(repo.webUrl))) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding = ViewGithubBinding.bind(view) - with(requireBinding.repoRecyclerView) { + with(binding.repoRecyclerView) { adapter = repoAdapter itemAnimator = null + addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)) } // action - requireBinding.searchEditText.textChanges() + binding.searchEditText.textChanges() .debounce(SearchDebounceMilliseconds) .map { it.toString() } - .map { GithubViewModel.Action.UpdateQuery(it) } + .map { SearchViewModel.Action.UpdateQuery(it) } .bind(to = viewModel.controller::dispatch) .launchIn(scope = viewLifecycleOwner.lifecycleScope) - requireBinding.repoRecyclerView.scrollEvents() + binding.repoRecyclerView.scrollEvents() .sample(500) .filter { it.view.shouldLoadMore() } - .map { GithubViewModel.Action.LoadNextPage } + .map { SearchViewModel.Action.LoadNextPage } .bind(to = viewModel.controller::dispatch) .launchIn(scope = viewLifecycleOwner.lifecycleScope) // state - viewModel.controller.state.distinctMap(by = GithubViewModel.State::repos) + viewModel.controller.state.distinctMap(by = SearchViewModel.State::repos) .bind(to = repoAdapter::submitList) .launchIn(scope = viewLifecycleOwner.lifecycleScope) - viewModel.controller.state.distinctMap(by = GithubViewModel.State::loadingNextPage) - .bind(to = requireBinding.loadingProgressBar::isVisible::set) + viewModel.controller.state.distinctMap(by = SearchViewModel.State::loadingNextPage) + .bind(to = binding.loadingProgressBar::isVisible::set) .launchIn(scope = viewLifecycleOwner.lifecycleScope) - } - override fun onDestroyView() { - super.onDestroyView() - binding = null + // effect + viewModel.controller.effects.onEach { effect -> + when (effect) { + is SearchViewModel.Effect.NetworkError -> { + binding.root.showSnackBar(R.string.info_network_error) + } + } + }.launchIn(scope = viewLifecycleOwner.lifecycleScope) } private fun RecyclerView.shouldLoadMore(threshold: Int = 8): Boolean { @@ -80,10 +85,5 @@ internal class GithubView : Fragment(R.layout.view_github) { companion object { const val SearchDebounceMilliseconds = 500L - - internal var GithubViewModelFactory = object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = GithubViewModel() as T - } } } diff --git a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/GithubViewModel.kt b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModel.kt similarity index 81% rename from examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/GithubViewModel.kt rename to examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModel.kt index 4684ee7..d1e5f47 100644 --- a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/GithubViewModel.kt +++ b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModel.kt @@ -2,12 +2,13 @@ package at.florianschuster.control.androidgithub.search import android.util.Log import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import at.florianschuster.control.ControllerEvent import at.florianschuster.control.ControllerLog -import at.florianschuster.control.createController import at.florianschuster.control.androidgithub.GithubApi -import at.florianschuster.control.androidgithub.Repo +import at.florianschuster.control.androidgithub.model.Repository +import at.florianschuster.control.createEffectController import at.florianschuster.control.takeUntil import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -19,7 +20,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -internal class GithubViewModel( +internal class SearchViewModel( initialState: State = State(), private val api: GithubApi = GithubApi(), controllerDispatcher: CoroutineDispatcher = Dispatchers.Default @@ -32,19 +33,23 @@ internal class GithubViewModel( sealed class Mutation { data class SetQuery(val query: String) : Mutation() - data class SetRepos(val repos: List) : Mutation() - data class AppendRepos(val repos: List) : Mutation() + data class SetRepos(val repos: List) : Mutation() + data class AppendRepos(val repos: List) : Mutation() data class SetLoadingNextPage(val loading: Boolean) : Mutation() } data class State( val query: String = "", - val repos: List = emptyList(), + val repos: List = emptyList(), val page: Int = 1, val loadingNextPage: Boolean = false ) - val controller = viewModelScope.createController( + sealed class Effect { + object NetworkError : Effect() + } + + val controller = viewModelScope.createEffectController( initialState = initialState, mutator = { action -> @@ -58,6 +63,7 @@ internal class GithubViewModel( emitAll( flow { emit(api.search(currentState.query, 1)) } .catch { error -> + offerEffect(Effect.NetworkError) Log.w("GithubViewModel", error) emit(emptyList()) } @@ -79,6 +85,7 @@ internal class GithubViewModel( val repos = kotlin.runCatching { api.search(state.query, state.page + 1) }.getOrElse { error -> + offerEffect(Effect.NetworkError) Log.w("GithubViewModel", error) emptyList() } @@ -109,4 +116,11 @@ internal class GithubViewModel( if (event is ControllerEvent.State) Log.d("GithubViewModel", message) } ) + + companion object { + internal var Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = SearchViewModel() as T + } + } } diff --git a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/snackbar.kt b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/snackbar.kt new file mode 100644 index 0000000..2f5219d --- /dev/null +++ b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/snackbar.kt @@ -0,0 +1,22 @@ +package at.florianschuster.control.androidgithub + +import android.view.View +import androidx.annotation.StringRes +import com.google.android.material.snackbar.BaseTransientBottomBar +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +suspend fun View.showSnackBar( + @StringRes messageResource: Int +) = suspendCancellableCoroutine { continuation -> + val snackbar = Snackbar + .make(this, messageResource, Snackbar.LENGTH_LONG) + .addCallback(object : BaseTransientBottomBar.BaseCallback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + continuation.resume(Unit) + } + }) + snackbar.show() + continuation.invokeOnCancellation { snackbar.dismiss() } +} \ No newline at end of file diff --git a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/viewbinding.kt b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/viewbinding.kt new file mode 100644 index 0000000..13aedef --- /dev/null +++ b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/viewbinding.kt @@ -0,0 +1,36 @@ +package at.florianschuster.control.androidgithub + +import android.view.View +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.viewbinding.ViewBinding +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +internal fun Fragment.viewBinding( + binder: (View) -> Binding +): ReadOnlyProperty = object : ReadOnlyProperty { + + private var binding: Binding? = null + + init { + viewLifecycleOwnerLiveData.observe(this@viewBinding) { viewLifecycleOwner -> + viewLifecycleOwner.lifecycle.addObserver( + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_DESTROY) { + binding = null + } + } + ) + } + } + + override fun getValue(thisRef: Fragment, property: KProperty<*>): Binding { + val binding = binding + if (binding != null) return binding + val lifecycleState = viewLifecycleOwner.lifecycle.currentState + check(lifecycleState.isAtLeast(Lifecycle.State.INITIALIZED)) { "fragment view is destroyed" } + return binder(thisRef.requireView()).also { this.binding = it } + } +} \ No newline at end of file diff --git a/examples/android-github/src/main/res/layout/item_repo.xml b/examples/android-github/src/main/res/layout/item_repo.xml index e3453d8..28fd57d 100644 --- a/examples/android-github/src/main/res/layout/item_repo.xml +++ b/examples/android-github/src/main/res/layout/item_repo.xml @@ -1,33 +1,62 @@ - + android:background="?selectableItemBackground"> - + android:padding="20dp"> + + - - \ No newline at end of file + + + + diff --git a/examples/android-github/src/main/res/layout/view_github.xml b/examples/android-github/src/main/res/layout/view_search.xml similarity index 97% rename from examples/android-github/src/main/res/layout/view_github.xml rename to examples/android-github/src/main/res/layout/view_search.xml index ac08851..b9eddc0 100644 --- a/examples/android-github/src/main/res/layout/view_github.xml +++ b/examples/android-github/src/main/res/layout/view_search.xml @@ -78,9 +78,6 @@ android:id="@+id/repoRecyclerView" android:layout_width="match_parent" android:layout_height="match_parent" - android:clipToPadding="false" - android:overScrollMode="never" - android:padding="6dp" android:scrollbars="vertical" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" diff --git a/examples/android-github/src/main/res/values/dimens.xml b/examples/android-github/src/main/res/values/dimens.xml new file mode 100644 index 0000000..25db9f9 --- /dev/null +++ b/examples/android-github/src/main/res/values/dimens.xml @@ -0,0 +1,5 @@ + + + 8dp + 16dp + \ No newline at end of file diff --git a/examples/android-github/src/main/res/values/strings.xml b/examples/android-github/src/main/res/values/strings.xml index 923c708..567f13e 100644 --- a/examples/android-github/src/main/res/values/strings.xml +++ b/examples/android-github/src/main/res/values/strings.xml @@ -2,4 +2,7 @@ github_control Search for repositories Search Icon + A network error occurred + Show More + last updated: %s diff --git a/examples/android-github/src/main/res/values/styles.xml b/examples/android-github/src/main/res/values/styles.xml index e58cbf3..677c24b 100644 --- a/examples/android-github/src/main/res/values/styles.xml +++ b/examples/android-github/src/main/res/values/styles.xml @@ -7,5 +7,4 @@ @color/colorPrimaryDark @color/colorAccent - diff --git a/examples/android-github/src/test/kotlin/at/florianschuster/control/androidgithub/search/GithubViewModelTest.kt b/examples/android-github/src/test/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModelTest.kt similarity index 53% rename from examples/android-github/src/test/kotlin/at/florianschuster/control/androidgithub/search/GithubViewModelTest.kt rename to examples/android-github/src/test/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModelTest.kt index fa82d5e..1fada5d 100644 --- a/examples/android-github/src/test/kotlin/at/florianschuster/control/androidgithub/search/GithubViewModelTest.kt +++ b/examples/android-github/src/test/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModelTest.kt @@ -1,7 +1,7 @@ package at.florianschuster.control.androidgithub.search import at.florianschuster.control.androidgithub.GithubApi -import at.florianschuster.control.androidgithub.Repo +import at.florianschuster.control.androidgithub.model.Repository import at.florianschuster.test.coroutines.TestCoroutineScopeRule import at.florianschuster.test.flow.TestFlow import at.florianschuster.test.flow.emissionCount @@ -16,9 +16,10 @@ import io.mockk.mockk import kotlinx.coroutines.delay import org.junit.Rule import org.junit.Test +import java.io.IOException import kotlin.test.assertFalse -internal class GithubViewModelTest { +internal class SearchViewModelTest { @get:Rule val testCoroutineScope = TestCoroutineScopeRule() @@ -27,32 +28,34 @@ internal class GithubViewModelTest { coEvery { search(any(), 1) } returns mockReposPage1 coEvery { search(any(), 2) } returns mockReposPage2 } - private lateinit var sut: GithubViewModel - private lateinit var states: TestFlow + private lateinit var sut: SearchViewModel + private lateinit var states: TestFlow + private lateinit var effects: TestFlow - private fun `given github search controller`( - initialState: GithubViewModel.State = GithubViewModel.State() + private fun `given ViewModel is created`( + initialState: SearchViewModel.State = SearchViewModel.State() ) { - sut = GithubViewModel(initialState, githubApi, testCoroutineScope.dispatcher) + sut = SearchViewModel(initialState, githubApi, testCoroutineScope.dispatcher) states = sut.controller.state.testIn(testCoroutineScope) + effects = sut.controller.effects.testIn(testCoroutineScope) } @Test fun `update query with non-empty text`() { // given - `given github search controller`() + `given ViewModel is created`() // when - sut.controller.dispatch(GithubViewModel.Action.UpdateQuery(query)) + sut.controller.dispatch(SearchViewModel.Action.UpdateQuery(query)) // then coVerify(exactly = 1) { githubApi.search(query, 1) } states expect emissions( - GithubViewModel.State(), - GithubViewModel.State(query = query), - GithubViewModel.State(query = query, loadingNextPage = true), - GithubViewModel.State(query, mockReposPage1, 1, true), - GithubViewModel.State(query, mockReposPage1, 1, false) + SearchViewModel.State(), + SearchViewModel.State(query = query), + SearchViewModel.State(query = query, loadingNextPage = true), + SearchViewModel.State(query, mockReposPage1, 1, true), + SearchViewModel.State(query, mockReposPage1, 1, false) ) } @@ -60,44 +63,44 @@ internal class GithubViewModelTest { fun `update query with empty text`() { // given val emptyQuery = "" - `given github search controller`() + `given ViewModel is created`() // when - sut.controller.dispatch(GithubViewModel.Action.UpdateQuery(emptyQuery)) + sut.controller.dispatch(SearchViewModel.Action.UpdateQuery(emptyQuery)) // then coVerify(exactly = 0) { githubApi.search(any(), any()) } - states expect lastEmission(GithubViewModel.State(query = emptyQuery)) + states expect lastEmission(SearchViewModel.State(query = emptyQuery)) } @Test fun `load next page loads correct next page`() { // given - `given github search controller`( - GithubViewModel.State(query = query, repos = mockReposPage1) + `given ViewModel is created`( + SearchViewModel.State(query = query, repos = mockReposPage1) ) // when - sut.controller.dispatch(GithubViewModel.Action.LoadNextPage) + sut.controller.dispatch(SearchViewModel.Action.LoadNextPage) // then coVerify(exactly = 1) { githubApi.search(any(), 2) } states expect emissions( - GithubViewModel.State(query = query, repos = mockReposPage1), - GithubViewModel.State(query, mockReposPage1, 1, true), - GithubViewModel.State(query, mockReposPage1 + mockReposPage2, 2, true), - GithubViewModel.State(query, mockReposPage1 + mockReposPage2, 2, false) + SearchViewModel.State(query = query, repos = mockReposPage1), + SearchViewModel.State(query, mockReposPage1, 1, true), + SearchViewModel.State(query, mockReposPage1 + mockReposPage2, 2, true), + SearchViewModel.State(query, mockReposPage1 + mockReposPage2, 2, false) ) } @Test fun `load next page only when currently not loading`() { // given - val initialState = GithubViewModel.State(loadingNextPage = true) - `given github search controller`(initialState) + val initialState = SearchViewModel.State(loadingNextPage = true) + `given ViewModel is created`(initialState) // when - sut.controller.dispatch(GithubViewModel.Action.LoadNextPage) + sut.controller.dispatch(SearchViewModel.Action.LoadNextPage) // then coVerify(exactly = 0) { githubApi.search(any(), any()) } @@ -108,20 +111,21 @@ internal class GithubViewModelTest { @Test fun `empty list from github api is correctly handled`() { // given - coEvery { githubApi.search(any(), any()) } returns emptyList() - `given github search controller`() + coEvery { githubApi.search(any(), any()) } throws IOException() + `given ViewModel is created`() // when - sut.controller.dispatch(GithubViewModel.Action.UpdateQuery(query)) + sut.controller.dispatch(SearchViewModel.Action.UpdateQuery(query)) // then coVerify(exactly = 1) { githubApi.search(query, 1) } states expect emissions( - GithubViewModel.State(), - GithubViewModel.State(query = query), - GithubViewModel.State(query = query, loadingNextPage = true), - GithubViewModel.State(query = query, loadingNextPage = false) + SearchViewModel.State(), + SearchViewModel.State(query = query), + SearchViewModel.State(query = query, loadingNextPage = true), + SearchViewModel.State(query = query, loadingNextPage = false) ) + effects expect emissions(SearchViewModel.Effect.NetworkError) } @Test @@ -135,12 +139,12 @@ internal class GithubViewModelTest { delay(1000) mockReposPage2 } - `given github search controller`() + `given ViewModel is created`() // when - sut.controller.dispatch(GithubViewModel.Action.UpdateQuery(query)) + sut.controller.dispatch(SearchViewModel.Action.UpdateQuery(query)) testCoroutineScope.advanceTimeBy(500) // updated before last query can finish - sut.controller.dispatch(GithubViewModel.Action.UpdateQuery(secondQuery)) + sut.controller.dispatch(SearchViewModel.Action.UpdateQuery(secondQuery)) testCoroutineScope.advanceUntilIdle() // then @@ -150,13 +154,25 @@ internal class GithubViewModelTest { } assertFalse(states.emissions.any { it.repos == mockReposPage1 }) states expect lastEmission( - GithubViewModel.State(query = secondQuery, repos = mockReposPage2) + SearchViewModel.State(query = secondQuery, repos = mockReposPage2) ) } companion object { - private val mockReposPage1: List = (0..2).map { Repo(it, "$it", "") } - private val mockReposPage2: List = (3..4).map { Repo(it, "$it", "") } + private val mockReposPage1: List = (0..2).map { + Repository( + it, "$it", "", "", + Repository.Owner("", ""), + "", "" + ) + } + private val mockReposPage2: List = (3..4).map { + Repository( + it, "$it", "", "", + Repository.Owner("", ""), + "", "" + ) + } private const val query = "control" private const val secondQuery = "controlAgain" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9233422..684e318 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.6-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.6.1-all.zip From 061051b4e67f97e074a6edf8425a05954f3d525c Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sun, 30 Aug 2020 22:21:24 +0200 Subject: [PATCH 07/12] Rename offerEffect to emitEffect --- .../florianschuster/control/EffectController.kt | 2 +- .../kotlin/at/florianschuster/control/errors.kt | 2 +- .../at/florianschuster/control/implementation.kt | 6 +++--- .../at/florianschuster/control/EventTest.kt | 2 +- .../control/ImplementationTest.kt | 16 ++++++++-------- .../androidgithub/search/SearchViewModel.kt | 4 ++-- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt b/control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt index d4c5492..60c06a1 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt @@ -104,7 +104,7 @@ interface EffectEmitter { /** * Emits an [Effect]. */ - fun offerEffect(effect: Effect) + fun emitEffect(effect: Effect) } /** diff --git a/control-core/src/main/kotlin/at/florianschuster/control/errors.kt b/control-core/src/main/kotlin/at/florianschuster/control/errors.kt index d683039..31028b4 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/errors.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/errors.kt @@ -31,7 +31,7 @@ internal sealed class ControllerError( ) /** - * Error during [EffectEmitter.offerEffect]. + * Error during [EffectEmitter.emitEffect]. */ class Effect( tag: String, diff --git a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt index 7199a30..32e3732 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt @@ -196,19 +196,19 @@ internal class ControllerImplementation( object : EffectMutatorContext { override val currentState: State get() = stateAccessor() override val actions: Flow = actionFlow - override fun offerEffect(effect: Effect) = effectEmitter(effect) + override fun emitEffect(effect: Effect) = effectEmitter(effect) } internal fun createReducerContext( emitter: (Effect) -> Unit ): EffectReducerContext = object : EffectReducerContext { - override fun offerEffect(effect: Effect) = emitter(effect) + override fun emitEffect(effect: Effect) = emitter(effect) } internal fun createTransformerContext( emitter: (Effect) -> Unit ): EffectTransformerContext = object : EffectTransformerContext { - override fun offerEffect(effect: Effect) = emitter(effect) + override fun emitEffect(effect: Effect) = emitter(effect) } } } \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt index 9427b94..6c290da 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt @@ -123,7 +123,7 @@ internal class EventTest { initialState = 0, mutator = { action -> flow { - if (action == effectValue) offerEffect(effectValue) + if (action == effectValue) emitEffect(effectValue) check(action != mutatorErrorValue) emit(action) } diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt index 577ec49..f6fa16d 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt @@ -193,7 +193,7 @@ internal class ImplementationTest { actions ) { emittedEffect = it } - sut.offerEffect(1) + sut.emitEffect(1) assertEquals(stateAccessor(), sut.currentState) assertEquals(actions, sut.actions) @@ -204,7 +204,7 @@ internal class ImplementationTest { fun `ReducerContext is built correctly`() { var emittedEffect: Int? = null val sut = ControllerImplementation.createReducerContext { emittedEffect = it } - sut.offerEffect(2) + sut.emitEffect(2) assertEquals(2, emittedEffect) } @@ -212,7 +212,7 @@ internal class ImplementationTest { fun `TransformerContext is built correctly`() { var emittedEffect: Int? = null val sut = ControllerImplementation.createTransformerContext { emittedEffect = it } - sut.offerEffect(3) + sut.emitEffect(3) assertEquals(3, emittedEffect) } @@ -422,31 +422,31 @@ internal class ImplementationTest { controllerStart = ControllerStart.Lazy, initialState = 0, mutator = { action -> - if (action == TestEffect.Mutator.ordinal) offerEffect(TestEffect.Mutator) + if (action == TestEffect.Mutator.ordinal) emitEffect(TestEffect.Mutator) flowOf(action) }, reducer = { mutation, _ -> - if (mutation == TestEffect.Reducer.ordinal) offerEffect(TestEffect.Reducer) + if (mutation == TestEffect.Reducer.ordinal) emitEffect(TestEffect.Reducer) mutation }, actionsTransformer = { actions -> actions.onEach { if (it == TestEffect.ActionTransformer.ordinal) { - offerEffect(TestEffect.ActionTransformer) + emitEffect(TestEffect.ActionTransformer) } } }, mutationsTransformer = { mutations -> mutations.onEach { if (it == TestEffect.MutationTransformer.ordinal) { - offerEffect(TestEffect.MutationTransformer) + emitEffect(TestEffect.MutationTransformer) } } }, statesTransformer = { states -> states.onEach { if (it == TestEffect.StateTransformer.ordinal) { - offerEffect(TestEffect.StateTransformer) + emitEffect(TestEffect.StateTransformer) } } }, diff --git a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModel.kt b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModel.kt index d1e5f47..b82aea3 100644 --- a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModel.kt +++ b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModel.kt @@ -63,7 +63,7 @@ internal class SearchViewModel( emitAll( flow { emit(api.search(currentState.query, 1)) } .catch { error -> - offerEffect(Effect.NetworkError) + emitEffect(Effect.NetworkError) Log.w("GithubViewModel", error) emit(emptyList()) } @@ -85,7 +85,7 @@ internal class SearchViewModel( val repos = kotlin.runCatching { api.search(state.query, state.page + 1) }.getOrElse { error -> - offerEffect(Effect.NetworkError) + emitEffect(Effect.NetworkError) Log.w("GithubViewModel", error) emptyList() } From 213049fba999ae1edb773447c5e21e6de738a225 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sun, 30 Aug 2020 22:26:00 +0200 Subject: [PATCH 08/12] Update EventTest --- .../test/kotlin/at/florianschuster/control/EventTest.kt | 9 +++++++++ .../test/kotlin/at/florianschuster/control/StubTest.kt | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt index 6c290da..1b3d802 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.TestCoroutineScope import org.junit.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue internal class EventTest { @@ -62,6 +63,10 @@ internal class EventTest { ) sut.toStub() assertTrue(events.last() is ControllerEvent.Stub) + + events.clear() + sut.toStub() + assertEquals(0, events.count()) } @Test @@ -73,6 +78,10 @@ internal class EventTest { ) sut.toStub() assertTrue(events.last() is ControllerEvent.Stub) + + events.clear() + sut.toStub() + assertEquals(0, events.count()) } @Test diff --git a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt index 5373eea..0c83eb5 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt @@ -57,7 +57,7 @@ internal class StubTest { val sut = testCoroutineScope.createStringController() assertFalse(sut.stubEnabled) - (sut as EffectController, List, Nothing>).toStub() + (sut as EffectController, List, String>).toStub() assertTrue(sut.stubEnabled) } From d427e0415741d8981612283df4f44f09a1121c3a Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sun, 30 Aug 2020 22:54:23 +0200 Subject: [PATCH 09/12] Update API --- control-core/api/control-core.api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control-core/api/control-core.api b/control-core/api/control-core.api index ff22ff7..4594130 100644 --- a/control-core/api/control-core.api +++ b/control-core/api/control-core.api @@ -92,7 +92,7 @@ public abstract interface class at/florianschuster/control/EffectControllerStub } public abstract interface class at/florianschuster/control/EffectEmitter { - public abstract fun offerEffect (Ljava/lang/Object;)V + public abstract fun emitEffect (Ljava/lang/Object;)V } public abstract interface class at/florianschuster/control/EffectMutatorContext : at/florianschuster/control/EffectEmitter, at/florianschuster/control/MutatorContext { From fb7c2d8eb4c61ca9fa015c076ec91840ffc4d3ca Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sun, 30 Aug 2020 22:54:42 +0200 Subject: [PATCH 10/12] Fix SearchViewModelTest --- .../androidgithub/search/SearchViewModelTest.kt | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/examples/android-github/src/test/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModelTest.kt b/examples/android-github/src/test/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModelTest.kt index 1fada5d..a3b4122 100644 --- a/examples/android-github/src/test/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModelTest.kt +++ b/examples/android-github/src/test/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModelTest.kt @@ -160,18 +160,10 @@ internal class SearchViewModelTest { companion object { private val mockReposPage1: List = (0..2).map { - Repository( - it, "$it", "", "", - Repository.Owner("", ""), - "", "" - ) + Repository(it, "$it", "", Repository.Owner(""), "", "") } private val mockReposPage2: List = (3..4).map { - Repository( - it, "$it", "", "", - Repository.Owner("", ""), - "", "" - ) + Repository(it, "$it", "", Repository.Owner(""), "", "") } private const val query = "control" From a4d5694519a29f81b20bb9b93962429680b9c55f Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Mon, 31 Aug 2020 11:56:28 +0200 Subject: [PATCH 11/12] Use Channel for stubbed Effect Flow. Also made all Flows cancellable --- .../at/florianschuster/control/extensions.kt | 8 --- .../florianschuster/control/implementation.kt | 49 ++++++++++--------- .../florianschuster/control/ExtensionsTest.kt | 13 ----- .../control/ImplementationTest.kt | 47 ++++++++++++++++-- 4 files changed, 68 insertions(+), 49 deletions(-) diff --git a/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt b/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt index 402fc00..c9e1d7a 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -90,10 +89,3 @@ fun Flow.takeUntil(other: Flow): Flow = flow { } private class TakeUntilException : CancellationException() - -/** - * Regular filterNotNull requires T : Any? - */ -internal fun Flow.filterNotNullCast(): Flow { - return filter { it != null }.map { checkNotNull(it) { "oh shi-" } } -} \ No newline at end of file diff --git a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt index 32e3732..02f32d4 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt @@ -100,31 +100,12 @@ internal class ControllerImplementation( // endregion - // region effects - - private val effectEmitter: (Effect) -> Unit = { effect -> - val canBeOffered = effectsChannel.offer(effect) - if (canBeOffered) { - controllerLog.log { ControllerEvent.Effect(tag, effect.toString()) } - } else { - throw ControllerError.Effect(tag, effect.toString()) - } - } - - private val effectsChannel = Channel(EFFECTS_CAPACITY) - override val effects: Flow - get() = if (stubEnabled) stubbedEffectFlow else { - effectsChannel.receiveAsFlow().cancellable() - } - - // endregion - // region controller override val state: Flow - get() = if (stubEnabled) stubbedStateFlow else { + get() = if (stubEnabled) stubbedStateFlow.cancellable() else { if (controllerStart is ControllerStart.Lazy) start() - mutableStateFlow + mutableStateFlow.cancellable() } override val currentState: State @@ -144,6 +125,27 @@ internal class ControllerImplementation( // endregion + // region effects + + private val effectEmitter: (Effect) -> Unit = { effect -> + val canBeOffered = effectsChannel.offer(effect) + if (canBeOffered) { + controllerLog.log { ControllerEvent.Effect(tag, effect.toString()) } + } else { + throw ControllerError.Effect(tag, effect.toString()) + } + } + + private val effectsChannel = Channel(EFFECTS_CAPACITY) + override val effects: Flow + get() = if (stubEnabled) { + stubbedEffectFlow.receiveAsFlow().cancellable() + } else { + effectsChannel.receiveAsFlow().cancellable() + } + + // endregion + // region manual start + stop internal fun start(): Boolean { @@ -162,8 +164,7 @@ internal class ControllerImplementation( private val stubbedActions = mutableListOf() private val stubbedStateFlow = MutableStateFlow(initialState) - private val _stubbedEffectFlow = MutableStateFlow(null) - private val stubbedEffectFlow = _stubbedEffectFlow.filterNotNullCast() + private val stubbedEffectFlow = Channel(EFFECTS_CAPACITY) override val dispatchedActions: List get() = stubbedActions @@ -173,7 +174,7 @@ internal class ControllerImplementation( } override fun emitEffect(effect: Effect) { - _stubbedEffectFlow.value = effect + stubbedEffectFlow.offer(effect) } // endregion diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt index 1231ddb..bb88110 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList @@ -61,16 +60,4 @@ internal class ExtensionsTest { val emptyResult = emptyFlow().takeUntil(flow { delay(1101); emit(Unit) }).toList() assertEquals(emptyList(), emptyResult) } - - @Test - fun `filterNotNullCast with empty flow`() = runBlockingTest { - val result = emptyFlow().filterNotNullCast().toList() - assertEquals(emptyList(), result) - } - - @Test - fun `filterNotNullCast with non-empty flow`() = runBlockingTest { - val result = flowOf(null, 1, 2, null).filterNotNullCast().toList() - assertEquals(listOf(1, 2), result) - } } \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt index f6fa16d..9a632d7 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt @@ -8,17 +8,22 @@ import at.florianschuster.test.flow.expect import at.florianschuster.test.flow.lastEmission import at.florianschuster.test.flow.testIn import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.runBlockingTest import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals @@ -234,7 +239,7 @@ internal class ImplementationTest { @Test fun `effects are received from mutator, reducer and transformer`() { - val sut = testCoroutineScope.createEffectController() + val sut = testCoroutineScope.createEffectTestController() val states = sut.state.testIn(testCoroutineScope) val effects = sut.effects.testIn(testCoroutineScope) @@ -254,7 +259,7 @@ internal class ImplementationTest { @Test fun `effects are only received once collector`() { - val sut = testCoroutineScope.createEffectController() + val sut = testCoroutineScope.createEffectTestController() val effects = mutableListOf() sut.effects.onEach { effects.add(it) }.launchIn(testCoroutineScope) sut.effects.onEach { effects.add(it) }.launchIn(testCoroutineScope) @@ -277,7 +282,7 @@ internal class ImplementationTest { @Test fun `effects overflow throws error`() { val scope = TestCoroutineScope() - val sut = scope.createEffectController() + val sut = scope.createEffectTestController() repeat(ControllerImplementation.EFFECTS_CAPACITY) { sut.dispatch(1) } assertTrue(scope.uncaughtExceptions.isEmpty()) @@ -289,6 +294,40 @@ internal class ImplementationTest { assertEquals(ControllerError.Effect::class, assertNotNull(error.cause)::class) } + @Test + fun `state is cancellable`() = runBlockingTest { + val sut = createCounterController() + + sut.dispatch(Unit) + + var state: Int? = null + launch { + cancel() + state = -1 + state = sut.state.first() // this should be cancelled and thus not return a value + } + + assertEquals(-1, state) + sut.cancel() + } + + @Test + fun `effects are cancellable`() = runBlockingTest { + val sut = createEffectTestController() + + sut.dispatch(TestEffect.Mutator.ordinal) + + var effect: TestEffect? = null + launch { + cancel() + effect = TestEffect.Reducer + effect = sut.effects.first() // this should be cancelled and thus not return a value + } + + assertEquals(TestEffect.Reducer, effect) + sut.cancel() + } + private fun CoroutineScope.createAlwaysSameStateController() = ControllerImplementation( scope = this, @@ -415,7 +454,7 @@ internal class ImplementationTest { Mutator, Reducer, ActionTransformer, MutationTransformer, StateTransformer } - private fun CoroutineScope.createEffectController() = + private fun CoroutineScope.createEffectTestController() = ControllerImplementation( scope = this, dispatcher = defaultScopeDispatcher(), From 3456a46d3a0f18114c0ec5ab8da78635a19bdc3b Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Mon, 31 Aug 2020 11:57:00 +0200 Subject: [PATCH 12/12] Remove unused import --- .../test/kotlin/at/florianschuster/control/ImplementationTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt index 9a632d7..f888d01 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt @@ -8,7 +8,6 @@ import at.florianschuster.test.flow.expect import at.florianschuster.test.flow.lastEmission import at.florianschuster.test.flow.testIn import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow