diff --git a/CHANGELOG.md b/CHANGELOG.md index ab450768..edf59a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ - binary compatibility will now be verified and held up on every release. +## `[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 - Remove `Mutation` generic type from `Controller` interface. diff --git a/buildSrc/src/main/kotlin/Libs.kt b/buildSrc/src/main/kotlin/Libs.kt index aa7a1b2c..78650f04 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 52e7b850..0f245645 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/control-core/api/control-core.api b/control-core/api/control-core.api index 13b5bd5e..45941300 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,37 @@ 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 emitEffect (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/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 +125,8 @@ 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/TransformerContext { diff --git a/control-core/build.gradle.kts b/control-core/build.gradle.kts index 698816e5..b7aeec26 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/Controller.kt b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt index b4ed031d..44c55ffb 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 00000000..60c06a11 --- /dev/null +++ b/control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt @@ -0,0 +1,143 @@ +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 + +/** + * 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 EffectController : Controller { + + /** + * [Flow] of [Effect]s. Use this to collect [Effect] emissions. + * The [Flow] is received in a fan-out fashion. One emission will be + * emitted to one collector only. + */ + val effects: Flow +} + +/** + * 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 +) + +/** + * 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 emitEffect(effect: Effect) +} + +/** + * 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/defaultDispatcher.kt b/control-core/src/main/kotlin/at/florianschuster/control/defaultDispatcher.kt index ee1c5380..3aacc996 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/errors.kt b/control-core/src/main/kotlin/at/florianschuster/control/errors.kt index 1ab3d3b7..31028b41 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.emitEffect]. + */ + 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 319050d8..8dcb3359 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 12c35551..02f32d44 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, EffectControllerStub { // 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,30 +100,23 @@ internal class ControllerImplementation( // 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.cancellable() else { if (controllerStart is ControllerStart.Lazy) start() - mutableStateFlow + mutableStateFlow.cancellable() } 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) { + stubbedActions.add(action) } else { if (controllerStart is ControllerStart.Lazy) start() actionChannel.offer(action) @@ -125,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 { @@ -137,6 +158,27 @@ internal class ControllerImplementation( // endregion + // region stub + + internal var stubEnabled = false + + private val stubbedActions = mutableListOf() + private val stubbedStateFlow = MutableStateFlow(initialState) + private val stubbedEffectFlow = Channel(EFFECTS_CAPACITY) + + override val dispatchedActions: List + get() = stubbedActions + + override fun emitState(state: State) { + stubbedStateFlow.value = state + } + + override fun emitEffect(effect: Effect) { + stubbedEffectFlow.offer(effect) + } + + // endregion + init { controllerLog.log { ControllerEvent.Created(tag, controllerStart.logName) } if (controllerStart is ControllerStart.Immediately) { @@ -145,16 +187,29 @@ internal class ControllerImplementation( } companion object { - fun createMutatorContext( + internal const val EFFECTS_CAPACITY = 64 + + 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 emitEffect(effect: Effect) = effectEmitter(effect) + } - fun createReducerContext(): ReducerContext = object : ReducerContext {} + internal fun createReducerContext( + emitter: (Effect) -> Unit + ): EffectReducerContext = object : EffectReducerContext { + override fun emitEffect(effect: Effect) = emitter(effect) + } - fun createTransformerContext(): TransformerContext = object : TransformerContext {} + internal fun createTransformerContext( + emitter: (Effect) -> Unit + ): EffectTransformerContext = object : EffectTransformerContext { + override fun emitEffect(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 73265dd5..2fa8ab04 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt @@ -2,33 +2,12 @@ package at.florianschuster.control import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -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]. @@ -44,19 +23,53 @@ interface ControllerStub { } /** - * An implementation of [ControllerStub]. + * 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 -internal class ControllerStubImplementation( - initialState: State -) : ControllerStub { +@FlowPreview +@TestOnly +fun Controller.toStub(): ControllerStub { + require(this is ControllerImplementation) { + "Cannot stub a custom implementation of a Controller." + } + if (!stubEnabled) { + controllerLog.log { ControllerEvent.Stub(tag) } + stubEnabled = true + } + return this +} - internal val mutableDispatchedActions = mutableListOf() - internal val stateFlow = MutableStateFlow(initialState) +/** + * A stub of a [EffectController] for view testing. + */ +interface EffectControllerStub : ControllerStub { - override val dispatchedActions: List get() = mutableDispatchedActions + /** + * Emits a new [Effect] for [EffectController.effects]. + * Use this to verify if [Effect] is correctly bound to a view. + */ + fun emitEffect(effect: Effect) +} - override fun emitState(state: State) { - stateFlow.value = state +/** + * Converts an [EffectController] to an [EffectControllerStub] for view testing. + * Once converted, the [EffectController] is stubbed and cannot be un-stubbed. + * + * Custom implementations of [EffectController] cannot be stubbed. + */ +@ExperimentalCoroutinesApi +@FlowPreview +@TestOnly +fun EffectController.toStub(): EffectControllerStub { + require(this is ControllerImplementation) { + "Cannot stub a custom implementation of a EffectController." + } + if (!stubEnabled) { + controllerLog.log { ControllerEvent.Stub(tag) } + stubEnabled = true } + return this } 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 16853d8a..8e71ebe0 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 00000000..cbca922b --- /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 70530f28..1b3d8025 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 { @@ -41,13 +42,48 @@ internal class EventTest { assertTrue(lastEvents[2] is ControllerEvent.State) } - sut.stub() - assertTrue(events.last() is ControllerEvent.Stub) + 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.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) + + events.clear() + sut.toStub() + assertEquals(0, events.count()) + } + + @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) + + events.clear() + sut.toStub() + assertEquals(0, events.count()) + } + @Test fun `ControllerImplementation logs mutator error correctly`() { val events = mutableListOf() @@ -72,16 +108,31 @@ 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 - ) = ControllerImplementation( + ) = ControllerImplementation( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = controllerStart, initialState = 0, mutator = { action -> flow { + if (action == effectValue) emitEffect(effectValue) check(action != mutatorErrorValue) emit(action) } @@ -100,5 +151,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 7a09e7df..f888d019 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt @@ -8,18 +8,25 @@ 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.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 +import kotlin.test.assertNotNull import kotlin.test.assertTrue internal class ImplementationTest { @@ -184,10 +191,33 @@ internal class ImplementationTest { fun `MutatorContext is built correctly`() { val stateAccessor = { 1 } val actions = flowOf(1) - val sut = ControllerImplementation.createMutatorContext(stateAccessor, actions) + var emittedEffect: Int? = null + val sut = ControllerImplementation.createMutatorContext( + stateAccessor, + actions + ) { emittedEffect = it } + + sut.emitEffect(1) assertEquals(stateAccessor(), sut.currentState) assertEquals(actions, sut.actions) + assertEquals(1, emittedEffect) + } + + @Test + fun `ReducerContext is built correctly`() { + var emittedEffect: Int? = null + val sut = ControllerImplementation.createReducerContext { emittedEffect = it } + sut.emitEffect(2) + assertEquals(2, emittedEffect) + } + + @Test + fun `TransformerContext is built correctly`() { + var emittedEffect: Int? = null + val sut = ControllerImplementation.createTransformerContext { emittedEffect = it } + sut.emitEffect(3) + assertEquals(3, emittedEffect) } @Test @@ -206,8 +236,99 @@ internal class ImplementationTest { states expect lastEmission(1) } + @Test + fun `effects are received from mutator, reducer and transformer`() { + val sut = testCoroutineScope.createEffectTestController() + 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.createEffectTestController() + 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) + } + + @Test + fun `effects overflow throws error`() { + val scope = TestCoroutineScope() + val sut = scope.createEffectTestController() + + 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) + } + + @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( + ControllerImplementation( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = ControllerStart.Lazy, @@ -222,7 +343,7 @@ internal class ImplementationTest { ) private fun CoroutineScope.createOperationController() = - ControllerImplementation, List, List>( + ControllerImplementation, List, List, Nothing>( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = ControllerStart.Lazy, @@ -258,7 +379,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 +407,7 @@ internal class ImplementationTest { } private fun CoroutineScope.createStopWatchController() = - ControllerImplementation( + ControllerImplementation( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = ControllerStart.Lazy, @@ -314,7 +435,7 @@ internal class ImplementationTest { private fun CoroutineScope.createGlobalStateMergeController( globalState: Flow - ) = ControllerImplementation( + ) = ControllerImplementation( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = ControllerStart.Lazy, @@ -327,4 +448,47 @@ internal class ImplementationTest { tag = "ImplementationTest.GlobalStateMergeController", controllerLog = ControllerLog.None ) + + enum class TestEffect { + Mutator, Reducer, ActionTransformer, MutationTransformer, StateTransformer + } + + private fun CoroutineScope.createEffectTestController() = + ControllerImplementation( + scope = this, + dispatcher = defaultScopeDispatcher(), + controllerStart = ControllerStart.Lazy, + initialState = 0, + mutator = { action -> + if (action == TestEffect.Mutator.ordinal) emitEffect(TestEffect.Mutator) + flowOf(action) + }, + reducer = { mutation, _ -> + if (mutation == TestEffect.Reducer.ordinal) emitEffect(TestEffect.Reducer) + mutation + }, + actionsTransformer = { actions -> + actions.onEach { + if (it == TestEffect.ActionTransformer.ordinal) { + emitEffect(TestEffect.ActionTransformer) + } + } + }, + mutationsTransformer = { mutations -> + mutations.onEach { + if (it == TestEffect.MutationTransformer.ordinal) { + emitEffect(TestEffect.MutationTransformer) + } + } + }, + statesTransformer = { states -> + states.onEach { + if (it == TestEffect.StateTransformer.ordinal) { + emitEffect(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/StartTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt index 44d3aee9..818acb30 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 fecb11b4..0c83eb55 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,37 @@ 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 `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.stubInitialized) + assertFalse(sut.stubEnabled) - assertFailsWith { sut.stub.dispatchedActions } - assertFalse(sut.stubInitialized) + (sut as Controller, List>).toStub() + assertTrue(sut.stubEnabled) + } - sut.stub().dispatchedActions - assertTrue(sut.stubInitialized) + @Test + fun `EffectController stub is enabled only after conversion()`() { + val sut = testCoroutineScope.createStringController() + assertFalse(sut.stubEnabled) + + (sut as EffectController, List, String>).toStub() + assertTrue(sut.stubEnabled) } @Test @@ -50,11 +68,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 +82,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,29 +97,40 @@ 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")) 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>( + ControllerImplementation, List, List, String>( scope = this, dispatcher = defaultScopeDispatcher(), controllerStart = ControllerStart.Lazy, 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 a008dbf4..89850430 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,28 +7,23 @@ 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 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 import kotlin.test.assertEquals -@RunWith(AndroidJUnit4::class) internal class CounterViewTest { private lateinit var stub: ControllerStub @Before fun setup() { - CounterView.CounterControllerProvider = { scope -> - val controller = scope.createCounterController() - stub = controller.stub() - controller + 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 faacdbb2..1c87320e 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 29baf5da..b923736c 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 eab68bba..df54035d 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,63 +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.stub +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 { - viewModel = GithubViewModel().apply { controller.stub() } + 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.stub().dispatchedActions.last() + SearchViewModel.Action.UpdateQuery(testQuery), + stub.dispatchedActions.last() ) } @Test - fun whenStateOffersLoadingNextPageThenProgressBarIsShown() { + fun whenStateOffersLoadingNextPage_ThenProgressBarIsShown() { // when - viewModel.controller.stub().emitState(GithubViewModel.State(loadingNextPage = true)) + stub.emitState(SearchViewModel.State(loadingNextPage = true)) // then onView(withId(R.id.loadingProgressBar)).check(matches(isDisplayed())) // when - viewModel.controller.stub().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 c78b0bc9..0c26d0da 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 7e299679..76183ba1 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 00000000..7a198f0a --- /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 3225d15c..00000000 --- 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 00000000..b3841383 --- /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 f5b026c9..6c74aee8 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,77 +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 android.widget.EditText 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 { @@ -81,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 4684ee73..b82aea31 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 -> + emitEffect(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 -> + emitEffect(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 00000000..2f5219d1 --- /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 00000000..13aedefa --- /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 e3453d87..28fd57db 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 ac088517..b9eddc07 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 00000000..25db9f98 --- /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 923c7087..567f13e9 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 e58cbf33..677c24b2 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 54% 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 fa82d5e5..a3b41226 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,17 @@ 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 9233422a..684e318d 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