diff --git a/CHANGELOG.md b/CHANGELOG.md index ec447fb..8cfb06d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ # changelog -## `[1.0.0]` - Unreleased +## `[1.0.0]` - 2022-04-11 -- binary compatibility will now be verified and held up on every release. +- Remove `Controller.currentState`. +- Remove `Flow.bind` and `Flow.distinctMap` extensions. +- Binary compatibility will now be verified and held up on every release. ## `[0.15.0]` - 2021-10-20 diff --git a/build.gradle.kts b/build.gradle.kts index c51b54c..882e906 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,20 +1,19 @@ buildscript { repositories { google() - jcenter() mavenCentral() maven(url = "https://plugins.gradle.org/m2/") } dependencies { - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31") - classpath("info.solidsoft.gradle.pitest:gradle-pitest-plugin:1.7.0") - classpath("org.jetbrains.kotlinx:binary-compatibility-validator:0.7.1") - classpath("com.vanniktech:gradle-maven-publish-plugin:0.18.0") - classpath("org.jetbrains.dokka:dokka-gradle-plugin:1.5.31") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10") + classpath("info.solidsoft.gradle.pitest:gradle-pitest-plugin:1.7.4") + classpath("org.jetbrains.kotlinx:binary-compatibility-validator:0.8.0") + classpath("com.vanniktech:gradle-maven-publish-plugin:0.19.0") + classpath("org.jetbrains.dokka:dokka-gradle-plugin:1.6.10") - classpath("com.android.tools.build:gradle:7.0.3") - classpath("org.jetbrains.kotlin:kotlin-serialization:1.5.31") + classpath("com.android.tools.build:gradle:7.0.4") + classpath("org.jetbrains.kotlin:kotlin-serialization:1.6.20") } } @@ -23,6 +22,7 @@ plugins { id("org.jlleitschuh.gradle.ktlint").version("10.0.0") `maven-publish` signing + id("com.github.ben-manes.versions").version("0.42.0") } // ---- api-validation --- // diff --git a/control-core/api/control-core.api b/control-core/api/control-core.api index f28c18c..6bcd44d 100644 --- a/control-core/api/control-core.api +++ b/control-core/api/control-core.api @@ -1,6 +1,5 @@ public abstract interface class at/florianschuster/control/Controller { public abstract fun dispatch (Ljava/lang/Object;)V - public abstract fun getCurrentState ()Ljava/lang/Object; public abstract fun getState ()Lkotlinx/coroutines/flow/StateFlow; } @@ -98,14 +97,6 @@ public abstract interface class at/florianschuster/control/EffectReducerContext 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; - public static final fun takeUntil (Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/flow/Flow;)Lkotlinx/coroutines/flow/Flow; - public static final fun takeUntil (Lkotlinx/coroutines/flow/Flow;ZLkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; - public static synthetic fun takeUntil$default (Lkotlinx/coroutines/flow/Flow;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; -} - public abstract interface class at/florianschuster/control/LoggerContext { public abstract fun getEvent ()Lat/florianschuster/control/ControllerEvent; } @@ -123,6 +114,12 @@ public final class at/florianschuster/control/StubKt { public static final fun toStub (Lat/florianschuster/control/EffectController;)Lat/florianschuster/control/EffectControllerStub; } +public final class at/florianschuster/control/TakeUntilKt { + public static final fun takeUntil (Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/flow/Flow;)Lkotlinx/coroutines/flow/Flow; + public static final fun takeUntil (Lkotlinx/coroutines/flow/Flow;ZLkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun takeUntil$default (Lkotlinx/coroutines/flow/Flow;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + public abstract interface class at/florianschuster/control/TransformerContext { } diff --git a/control-core/build.gradle.kts b/control-core/build.gradle.kts index d997934..7c3d2bc 100644 --- a/control-core/build.gradle.kts +++ b/control-core/build.gradle.kts @@ -6,47 +6,41 @@ plugins { } dependencies { - api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2") - testImplementation("io.mockk:mockk:1.12.0") - testImplementation("at.florianschuster.test:coroutines-test-extensions:0.1.2") -} - -// ---- kotlin --- // + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1") -tasks.compileTestKotlin { - kotlinOptions.freeCompilerArgs = listOf( - "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-Xuse-experimental=kotlinx.coroutines.FlowPreview" - ) + testImplementation(kotlin("test")) + testImplementation("io.mockk:mockk:1.12.3") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1") } -// ---- end kotlin --- // - // ---- jacoco --- // tasks.jacocoTestCoverageVerification { violationRules { - rule { limit { minimum = "0.94".toBigDecimal() } } + rule { limit { minimum = "0.95".toBigDecimal() } } } + classDirectories.setFrom( + sourceSets.main.get().output.asFileTree.matching { + // jacoco cannot handle inline functions properly + exclude( + "at/florianschuster/control/DefaultTagKt*", + "at/florianschuster/control/TakeUntilKt*", + ) + // builders + exclude( + "at/florianschuster/control/ControllerKt*", + "at/florianschuster/control/EffectControllerKt*", + ) + } + ) } tasks.jacocoTestReport { reports { - xml.isEnabled = true - html.isEnabled = true - csv.isEnabled = false + xml.required.set(true) + html.required.set(true) + csv.required.set(false) } - classDirectories.setFrom( - files(classDirectories.files.map { file -> - fileTree(file) { - // jacoco cannot handle inline functions properly - exclude( - "at/florianschuster/control/DefaultTagKt.class", - "at/florianschuster/control/ExtensionsKt.class" - ) - } - }) - ) } // ---- end jacoco --- // @@ -57,8 +51,13 @@ pitest { targetClasses.add("at.florianschuster.control.*") mutationThreshold.set(100) excludedClasses.addAll( - "at.florianschuster.control.DefaultTagKt**", // inline function - "at.florianschuster.control.ExtensionsKt**", // too many inline collects + // inline function + "at.florianschuster.control.DefaultTagKt**", + "at.florianschuster.control.TakeUntilKt**", + + // builder + "at.florianschuster.control.Controller**", + "at.florianschuster.control.EffectController**", // inlined invokeSuspend "at.florianschuster.control.ControllerImplementation\$stateJob\$1", 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 278b114..2335439 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt @@ -3,7 +3,6 @@ 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.StateFlow @@ -42,15 +41,6 @@ interface Controller { */ fun dispatch(action: Action) - /** - * The current [State]. - */ - @Deprecated( - message = "Use state.value instead.", - replaceWith = ReplaceWith("state.value") - ) - val currentState: State - /** * The [State]. Use this to collect [State] changes * or get the current [State] via [StateFlow.value]. @@ -100,7 +90,6 @@ interface Controller { * 3. [Transformer] * 4. [ControllerImplementation] */ -@ExperimentalCoroutinesApi @FlowPreview fun CoroutineScope.createController( @@ -188,7 +177,7 @@ fun CoroutineScope.createController( typealias Mutator = MutatorContext.(action: Action) -> Flow /** - * The [MutatorContext] provides access to the [currentState] and the [actions] [Flow] in + * The [MutatorContext] provides access to the current [State] and the [actions] [Flow] in * a [Mutator]. */ interface MutatorContext { 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 ea19309..368d7a9 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/EffectController.kt @@ -3,7 +3,6 @@ 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 @@ -37,7 +36,6 @@ interface EffectController : Controller { * An [Effect] can be emitted either in [mutator], [reducer], [actionsTransformer], * [mutationsTransformer] or [statesTransformer]. */ -@ExperimentalCoroutinesApi @FlowPreview fun CoroutineScope.createEffectController( @@ -99,7 +97,7 @@ fun CoroutineScope.createEffectController( * This is implemented by the respective context's of [EffectMutator], [EffectReducer] * and [EffectTransformer]. */ -interface EffectEmitter { +fun interface EffectEmitter { /** * Emits an [Effect]. 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 3aacc99..855e614 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/defaultDispatcher.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/defaultDispatcher.kt @@ -9,7 +9,7 @@ import kotlin.coroutines.ContinuationInterceptor */ internal fun CoroutineScope.defaultScopeDispatcher(): CoroutineDispatcher { val continuationInterceptor = coroutineContext[ContinuationInterceptor] - checkNotNull(continuationInterceptor) { + requireNotNull(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 83d0bcb..1d293ef 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt @@ -4,7 +4,6 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -28,7 +27,6 @@ import kotlinx.coroutines.launch /** * An implementation of [Controller]. */ -@ExperimentalCoroutinesApi @FlowPreview internal class ControllerImplementation( val scope: CoroutineScope, @@ -61,9 +59,8 @@ internal class ControllerImplementation( ) { val transformerContext = createTransformerContext(effectEmitter) - val actionFlow: Flow = transformerContext.actionsTransformer( - actionSharedFlow.asSharedFlow() - ) + val actionFlow: Flow = transformerContext + .actionsTransformer(actionSharedFlow.asSharedFlow()) val mutatorContext = createMutatorContext( stateAccessor = { state.value }, @@ -116,15 +113,6 @@ internal class ControllerImplementation( mutableStateFlow.asStateFlow() } - @Suppress("OverridingDeprecatedMember") - override val currentState: State - get() = if (stubEnabled) { - stubbedStateFlow.value - } else { - if (controllerStart is ControllerStart.Lazy) start() - mutableStateFlow.value - } - override fun dispatch(action: Action) { if (stubEnabled) { stubbedActions.add(action) 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 a6c8b8d..b9f6d32 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt @@ -1,6 +1,5 @@ package at.florianschuster.control -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import org.jetbrains.annotations.TestOnly @@ -28,7 +27,6 @@ interface ControllerStub : Controller { * * Custom implementations of [Controller] cannot be stubbed. */ -@ExperimentalCoroutinesApi @FlowPreview @TestOnly fun Controller.toStub(): ControllerStub { @@ -60,7 +58,6 @@ interface EffectControllerStub : ControllerStub EffectController.toStub(): EffectControllerStub { diff --git a/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt b/control-core/src/main/kotlin/at/florianschuster/control/takeUntil.kt similarity index 75% rename from control-core/src/main/kotlin/at/florianschuster/control/extensions.kt rename to control-core/src/main/kotlin/at/florianschuster/control/takeUntil.kt index c9e1d7a..6bd9414 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/takeUntil.kt @@ -1,32 +1,11 @@ package at.florianschuster.control import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -/** - * Binds a [Flow] to an non suspending block. - */ -fun Flow.bind( - to: (T) -> Unit -): Flow = onEach { to(it) } - -/** - * Maps emissions of a [Flow] and only emits those that are distinct from their immediate - * predecessors. - */ -@ExperimentalCoroutinesApi -fun Flow.distinctMap( - by: (State) -> SubState -): Flow = map { by(it) }.distinctUntilChanged() - /** * Discard any emissions by a [Flow] of [T] if emission matches [predicate]. * If [inclusive] is true, the last emission matching the [predicate] will be emitted. 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 26060cd..a6c87b1 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/CreateControllerTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/CreateControllerTest.kt @@ -3,10 +3,11 @@ package at.florianschuster.control import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.single import kotlinx.coroutines.flow.singleOrNull -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals @@ -16,7 +17,7 @@ import kotlin.test.assertEquals internal class CreateControllerTest { @Test - fun `default parameters of controller builder`() = runBlockingTest { + fun `controller builder`() = runTest { val expectedInitialState = 42 val sut = createController( initialState = expectedInitialState @@ -37,10 +38,12 @@ internal class CreateControllerTest { assertEquals(ControllerStart.Lazy, sut.controllerStart) assertEquals(defaultScopeDispatcher(), sut.dispatcher) + + coroutineContext.cancelChildren() } @Test - fun `default parameters of effect controller builder`() = runBlockingTest { + fun `effect controller builder`() = runTest { val expectedInitialState = 42 val sut = createEffectController( initialState = expectedInitialState @@ -61,5 +64,7 @@ internal class CreateControllerTest { assertEquals(ControllerStart.Lazy, sut.controllerStart) assertEquals(defaultScopeDispatcher(), sut.dispatcher) + + coroutineContext.cancelChildren() } } diff --git a/control-core/src/test/kotlin/at/florianschuster/control/DefaultScopeDispatcherTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/DefaultScopeDispatcherTest.kt index 15268a3..e807c6b 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/DefaultScopeDispatcherTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/DefaultScopeDispatcherTest.kt @@ -4,9 +4,8 @@ import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher import org.junit.Test -import java.lang.IllegalStateException import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -24,7 +23,7 @@ internal class DefaultScopeDispatcherTest { @Test fun `test dispatcher`() { - val expectedDispatcher = TestCoroutineDispatcher() + val expectedDispatcher = StandardTestDispatcher() assertEquals( expectedDispatcher, CoroutineScope(expectedDispatcher).defaultScopeDispatcher() @@ -34,6 +33,6 @@ internal class DefaultScopeDispatcherTest { @Test fun `scope without interceptor fails`() { val scope = CoroutineScope(CoroutineName("name")) - assertFailsWith { scope.defaultScopeDispatcher() } + assertFailsWith { scope.defaultScopeDispatcher() } } } \ 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 51166c7..6825c43 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt @@ -3,11 +3,9 @@ package at.florianschuster.control import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -28,7 +26,7 @@ internal class EventTest { @Test fun `ControllerImplementation logs events correctly`() { val events = mutableListOf() - val sut = TestCoroutineScope().eventsController( + val sut = TestScope(UnconfinedTestDispatcher()).eventsController( events, controllerStart = ControllerStart.Manual ) @@ -64,7 +62,7 @@ internal class EventTest { @Test fun `ControllerStub logs event correctly`() { val events = mutableListOf() - val sut: Controller = TestCoroutineScope().eventsController( + val sut: Controller = TestScope().eventsController( events, controllerStart = ControllerStart.Manual ) @@ -79,7 +77,7 @@ internal class EventTest { @Test fun `EffectControllerStub logs event correctly`() { val events = mutableListOf() - val sut: EffectController = TestCoroutineScope().eventsController( + val sut: EffectController = TestScope().eventsController( events, controllerStart = ControllerStart.Manual ) @@ -94,7 +92,7 @@ internal class EventTest { @Test fun `ControllerImplementation logs mutator error correctly`() { val events = mutableListOf() - val sut = TestCoroutineScope().eventsController(events) + val sut = TestScope(UnconfinedTestDispatcher()).eventsController(events) sut.dispatch(mutatorErrorValue) events.takeLast(2).let { lastEvents -> @@ -106,7 +104,7 @@ internal class EventTest { @Test fun `ControllerImplementation logs reducer error correctly`() { val events = mutableListOf() - val sut = TestCoroutineScope().eventsController(events) + val sut = TestScope(UnconfinedTestDispatcher()).eventsController(events) sut.dispatch(reducerErrorValue) events.takeLast(2).let { lastEvents -> @@ -117,9 +115,8 @@ internal class EventTest { @Test fun `ControllerImplementation logs effect error correctly`() { - val scope = TestCoroutineScope() val events = mutableListOf() - val sut = scope.eventsController(events) + val sut = TestScope(UnconfinedTestDispatcher()).eventsController(events) repeat(ControllerImplementation.CAPACITY) { sut.dispatch(effectValue) } sut.dispatch(effectValue) 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 49ce022..716b151 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt @@ -1,12 +1,5 @@ package at.florianschuster.control -import at.florianschuster.test.coroutines.TestCoroutineScopeRule -import at.florianschuster.test.flow.emission -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.testIn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -22,43 +15,41 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest -import org.junit.Rule +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals -import kotlin.test.assertNotNull import kotlin.test.assertTrue @FlowPreview @ExperimentalCoroutinesApi internal class ImplementationTest { - @get:Rule - val testCoroutineScope = TestCoroutineScopeRule() - @Test fun `initial state only emitted once`() { - val sut = testCoroutineScope.createOperationController() - val testFlow = sut.state.testIn(testCoroutineScope) + val scope = TestScope(UnconfinedTestDispatcher()) + val sut = scope.createOperationController() + val states = sut.state.testIn(scope) - testFlow expect emissionCount(1) - testFlow expect emission(0, listOf("initialState", "transformedState")) + assertEquals(listOf("initialState", "transformedState"), states.single()) } @Test fun `state is created when accessing current state`() { - val sut = testCoroutineScope.createOperationController() - assertEquals(listOf("initialState", "transformedState"), sut.currentState) - - val sut2 = testCoroutineScope.createOperationController() - assertEquals(listOf("initialState", "transformedState"), sut2.state.value) + val scope = TestScope(UnconfinedTestDispatcher()) + val sut = scope.createOperationController() + assertEquals(listOf("initialState", "transformedState"), sut.state.value) } @Test fun `state is created when accessing action`() { - val sut = testCoroutineScope.createOperationController() + val scope = TestScope(UnconfinedTestDispatcher()) + val sut = scope.createOperationController() sut.dispatch(listOf("action")) @@ -77,104 +68,125 @@ internal class ImplementationTest { @Test fun `each method is invoked`() { - val sut = testCoroutineScope.createOperationController() - val testFlow = sut.state.testIn(testCoroutineScope) + val scope = TestScope(UnconfinedTestDispatcher()) + val sut = scope.createOperationController() + val states = sut.state.testIn(scope) sut.dispatch(listOf("action")) - testFlow expect emissionCount(2) - testFlow expect emissions( - listOf("initialState", "transformedState"), + assertEquals( listOf( - "initialState", - "action", - "transformedAction", - "mutation", - "transformedMutation", - "transformedState" - ) + listOf("initialState", "transformedState"), + listOf( + "initialState", + "action", + "transformedAction", + "mutation", + "transformedMutation", + "transformedState" + ) + ), + states ) + + scope.cancel() } @Test fun `only distinct states are emitted`() { - val sut = testCoroutineScope.createAlwaysSameStateController() - val testFlow = sut.state.testIn(testCoroutineScope) + val scope = TestScope(UnconfinedTestDispatcher()) + val sut = scope.createAlwaysSameStateController() + val states = sut.state.testIn(scope) sut.dispatch(Unit) sut.dispatch(Unit) sut.dispatch(Unit) - testFlow expect emissionCount(1) // no state changes + assertEquals(1, states.count()) // no state changes } @Test fun `collector receives latest and following states`() { - val sut = testCoroutineScope.createCounterController() // 0 + val scope = TestScope(UnconfinedTestDispatcher()) + val sut = scope.createCounterController() // 0 sut.dispatch(Unit) // 1 sut.dispatch(Unit) // 2 sut.dispatch(Unit) // 3 sut.dispatch(Unit) // 4 - val testFlow = sut.state.testIn(testCoroutineScope) + val states = sut.state.testIn(scope) sut.dispatch(Unit) // 5 - testFlow expect emissions(4, 5) + assertEquals( + listOf(4, 5), + states + ) } @Test - fun `state flow throws error from mutator`() { - val scope = TestCoroutineScope() - val sut = scope.createCounterController(mutatorErrorIndex = 2) - sut.dispatch(Unit) - sut.dispatch(Unit) - sut.dispatch(Unit) - - assertTrue(scope.uncaughtExceptions.first() is ControllerError.Mutate) + fun `controller throws error from mutator`() { + kotlin.runCatching { + runTest(UnconfinedTestDispatcher()) { + val sut = createCounterController(mutatorErrorIndex = 2) + sut.dispatch(Unit) + sut.dispatch(Unit) + sut.dispatch(Unit) + } + }.fold( + onSuccess = { error("this should not succeed") }, + onFailure = { assertTrue(it is ControllerError.Mutate) } + ) } @Test - fun `state flow throws error from reducer`() { - val scope = TestCoroutineScope() - val sut = scope.createCounterController(reducerErrorIndex = 2) - - sut.dispatch(Unit) - sut.dispatch(Unit) - sut.dispatch(Unit) - - assertTrue(scope.uncaughtExceptions.first() is ControllerError.Reduce) + fun `controller throws error from reducer`() { + kotlin.runCatching { + runTest(UnconfinedTestDispatcher()) { + val sut = createCounterController(reducerErrorIndex = 2) + sut.dispatch(Unit) + sut.dispatch(Unit) + sut.dispatch(Unit) + } + }.fold( + onSuccess = { error("this should not succeed") }, + onFailure = { assertTrue(it is ControllerError.Reduce) } + ) } @Test fun `cancel via takeUntil`() { - val sut = testCoroutineScope.createStopWatchController() + val scope = TestScope(UnconfinedTestDispatcher()) + val sut = scope.createStopWatchController() sut.dispatch(StopWatchAction.Start) - testCoroutineScope.advanceTimeBy(2000) + scope.advanceTimeBy(MinimumStopWatchDelay * 2 + 1) sut.dispatch(StopWatchAction.Stop) + assertEquals(2, sut.state.value) sut.dispatch(StopWatchAction.Start) - testCoroutineScope.advanceTimeBy(3000) + scope.advanceTimeBy(MinimumStopWatchDelay * 3 + 1) sut.dispatch(StopWatchAction.Stop) + assertEquals(5, sut.state.value) sut.dispatch(StopWatchAction.Start) - testCoroutineScope.advanceTimeBy(4000) + scope.advanceTimeBy(MinimumStopWatchDelay * 4 + 1) sut.dispatch(StopWatchAction.Stop) + assertEquals(9, sut.state.value) - // this should be ignored sut.dispatch(StopWatchAction.Start) - testCoroutineScope.advanceTimeBy(500) + scope.advanceTimeBy(MinimumStopWatchDelay / 2) sut.dispatch(StopWatchAction.Stop) + assertEquals(9, sut.state.value) sut.dispatch(StopWatchAction.Start) - testCoroutineScope.advanceTimeBy(1000) + scope.advanceTimeBy(MinimumStopWatchDelay + 1) sut.dispatch(StopWatchAction.Stop) + assertEquals(10, sut.state.value) - assertTrue(sut.state.value == 10) // 2+3+4+1 - - testCoroutineScope.advanceUntilIdle() + scope.cancel() } @Test fun `global state gets merged into controller`() { + val scope = TestScope(UnconfinedTestDispatcher()) val globalState = flow { delay(250) emit(42) @@ -182,15 +194,19 @@ internal class ImplementationTest { emit(42) } - val sut = testCoroutineScope.createGlobalStateMergeController(globalState) + val sut = scope.createGlobalStateMergeController(globalState) - val states = sut.state.testIn(testCoroutineScope) + val states = sut.state.testIn(scope) - testCoroutineScope.advanceTimeBy(251) + scope.advanceTimeBy(251) sut.dispatch(1) - testCoroutineScope.advanceTimeBy(251) + scope.advanceTimeBy(251) - states expect emissions(0, 42, 43, 85) + assertEquals( + listOf(0, 42, 43, 85), + states + ) + scope.cancel() } @Test @@ -228,9 +244,10 @@ internal class ImplementationTest { @Test fun `cancelling the implementation will return the last state`() { - val sut = testCoroutineScope.createGlobalStateMergeController(emptyFlow()) + val scope = TestScope(UnconfinedTestDispatcher()) + val sut = scope.createGlobalStateMergeController(emptyFlow()) - val states = sut.state.testIn(testCoroutineScope) + val states = sut.state.testIn(scope) sut.dispatch(0) sut.dispatch(1) @@ -239,14 +256,17 @@ internal class ImplementationTest { sut.dispatch(2) - states expect lastEmission(1) + assertEquals(1, states.last()) + + scope.cancel() } @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 scope = TestScope(UnconfinedTestDispatcher()) + val sut = scope.createEffectTestController() + val states = sut.state.testIn(scope) + val effects = sut.effects.testIn(scope) val testEmissions = listOf( TestEffect.Reducer, @@ -258,16 +278,21 @@ internal class ImplementationTest { testEmissions.map(TestEffect::ordinal).forEach(sut::dispatch) - states expect emissions(listOf(0) + testEmissions.map(TestEffect::ordinal)) - effects expect emissions(testEmissions) + assertEquals( + listOf(0) + testEmissions.map(TestEffect::ordinal), + states + ) + assertEquals(testEmissions, effects) + scope.cancel() } @Test - fun `effects are only received once collector`() { - val sut = testCoroutineScope.createEffectTestController() + fun `effects are only received once per collector`() { + val scope = TestScope(UnconfinedTestDispatcher()) + val sut = scope.createEffectTestController() val effects = mutableListOf() - sut.effects.onEach { effects.add(it) }.launchIn(testCoroutineScope) - sut.effects.onEach { effects.add(it) }.launchIn(testCoroutineScope) + sut.effects.onEach { effects.add(it) }.launchIn(scope) + sut.effects.onEach { effects.add(it) }.launchIn(scope) val testEmissions = listOf( TestEffect.Reducer, @@ -286,21 +311,19 @@ internal class ImplementationTest { @Test fun `effects overflow throws error`() { - val scope = TestCoroutineScope() - val sut = scope.createEffectTestController() - - repeat(ControllerImplementation.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) + kotlin.runCatching { + runTest(UnconfinedTestDispatcher()) { + val sut = createEffectTestController() + repeat(ControllerImplementation.CAPACITY + 1) { sut.dispatch(1) } + } + }.fold( + onSuccess = { error("this should not succeed") }, + onFailure = { assertTrue(it.cause is ControllerError.Effect) } + ) } @Test - fun `state is cancellable`() = runBlockingTest { + fun `state is cancellable`() = runTest(UnconfinedTestDispatcher()) { val sut = createCounterController() sut.dispatch(Unit) @@ -317,7 +340,7 @@ internal class ImplementationTest { } @Test - fun `effects are cancellable`() = runBlockingTest { + fun `effects are cancellable`() = runTest(UnconfinedTestDispatcher()) { val sut = createEffectTestController() sut.dispatch(TestEffect.Mutator.ordinal) @@ -335,9 +358,10 @@ internal class ImplementationTest { @Test fun `controller is started lazily when only effects field is accessed`() { + val scope = TestScope(UnconfinedTestDispatcher()) val sut = ControllerImplementation( - scope = testCoroutineScope, - dispatcher = testCoroutineScope.defaultScopeDispatcher(), + scope = scope, + dispatcher = scope.defaultScopeDispatcher(), controllerStart = ControllerStart.Lazy, initialState = 0, mutator = { action -> flowOf(action) }, @@ -353,9 +377,14 @@ internal class ImplementationTest { controllerLog = ControllerLog.None ) - val effects = sut.effects.testIn(testCoroutineScope) + val effects = sut.effects.testIn(scope) + + assertEquals( + listOf("actionsTransformer started"), + effects + ) - effects expect emissions(listOf("actionsTransformer started")) + scope.cancel() } private fun CoroutineScope.createAlwaysSameStateController() = @@ -441,14 +470,14 @@ internal class ImplementationTest { ControllerImplementation( scope = this, dispatcher = defaultScopeDispatcher(), - controllerStart = ControllerStart.Lazy, + controllerStart = ControllerStart.Immediately, initialState = 0, mutator = { action -> when (action) { is StopWatchAction.Start -> { flow { - while (true) { - delay(1000) + while (isActive) { + delay(MinimumStopWatchDelay) emit(1) } }.takeUntil(actions.filterIsInstance()) @@ -522,4 +551,14 @@ internal class ImplementationTest { tag = "ImplementationTest.EffectController", controllerLog = ControllerLog.None ) + + companion object { + private const val MinimumStopWatchDelay = 1000L + } +} + +private fun Flow.testIn(scope: CoroutineScope): List { + val emissions = mutableListOf() + scope.launch { toList(emissions) } + return emissions } \ 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 7b4150d..2948e2a 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.TestScope import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -25,7 +25,7 @@ internal class StartTest { @Test fun `default start mode`() { - val scope = TestCoroutineScope(Job()) + val scope = TestScope(Job()) val sut = scope.createSimpleCounterController( controllerStart = ControllerStart.Immediately ) @@ -35,24 +35,9 @@ internal class StartTest { assertFalse(sut.stateJob.isActive) } - @Test - fun `lazy start mode with currentState`() { - val scope = TestCoroutineScope(Job()) - val sut = scope.createSimpleCounterController( - controllerStart = ControllerStart.Lazy - ) - assertFalse(sut.stateJob.isActive) - - sut.currentState - assertTrue(sut.stateJob.isActive) - - scope.cancel() - assertFalse(sut.stateJob.isActive) - } - @Test fun `lazy start mode with state`() { - val scope = TestCoroutineScope(Job()) + val scope = TestScope(Job()) val sut = scope.createSimpleCounterController( controllerStart = ControllerStart.Lazy ) @@ -67,7 +52,7 @@ internal class StartTest { @Test fun `lazy start mode with state_value`() { - val scope = TestCoroutineScope(Job()) + val scope = TestScope(Job()) val sut = scope.createSimpleCounterController( controllerStart = ControllerStart.Lazy ) @@ -82,7 +67,7 @@ internal class StartTest { @Test fun `lazy start mode with dispatch`() { - val scope = TestCoroutineScope(Job()) + val scope = TestScope(Job()) val sut = scope.createSimpleCounterController( controllerStart = ControllerStart.Lazy ) @@ -97,7 +82,7 @@ internal class StartTest { @Test fun `lazy start mode with effects`() { - val scope = TestCoroutineScope(Job()) + val scope = TestScope(Job()) val sut = scope.createSimpleCounterController( controllerStart = ControllerStart.Lazy ) @@ -112,13 +97,12 @@ internal class StartTest { @Test fun `manual start mode`() { - val scope = TestCoroutineScope(Job()) + val scope = TestScope(Job()) val sut = scope.createSimpleCounterController( controllerStart = ControllerStart.Manual ) assertFalse(sut.stateJob.isActive) - sut.currentState sut.state sut.state.value sut.dispatch(1) @@ -135,7 +119,7 @@ internal class StartTest { @Test fun `manual start mode, start when already started`() { - val sut = TestCoroutineScope().createSimpleCounterController( + val sut = TestScope().createSimpleCounterController( controllerStart = ControllerStart.Manual ) assertFalse(sut.stateJob.isActive) @@ -148,7 +132,7 @@ internal class StartTest { @Test fun `manual start mode, cancel implementation`() { - val sut = TestCoroutineScope().createSimpleCounterController( + val sut = TestScope().createSimpleCounterController( controllerStart = ControllerStart.Manual ) assertFalse(sut.stateJob.isActive) 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 b5b0e9a..709ac2c 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt @@ -1,16 +1,16 @@ package at.florianschuster.control -import at.florianschuster.test.coroutines.TestCoroutineScopeRule -import at.florianschuster.test.flow.emissions -import at.florianschuster.test.flow.expect -import at.florianschuster.test.flow.testIn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf -import org.junit.Rule +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Test import java.lang.IllegalArgumentException import kotlin.test.assertEquals @@ -22,14 +22,10 @@ import kotlin.test.assertTrue @ExperimentalCoroutinesApi internal class StubTest { - @get:Rule - val testCoroutineScope = TestCoroutineScopeRule() - @Test fun `custom controller implementation cannot be stubbed`() { val sut = object : Controller { override fun dispatch(action: Int) = Unit - override val currentState: Int get() = error("") override val state: StateFlow get() = error("") } @@ -40,7 +36,6 @@ internal class StubTest { 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: StateFlow get() = error("") override val effects: Flow get() = error("") } @@ -50,88 +45,118 @@ internal class StubTest { @Test fun `Controller stub is enabled only after conversion()`() { - val sut = testCoroutineScope.createStringController() + val scope = TestScope() + val sut = scope.createStringController() assertFalse(sut.stubEnabled) (sut as Controller, List>).toStub() assertTrue(sut.stubEnabled) + + scope.cancel() } @Test fun `EffectController stub is enabled only after conversion()`() { - val sut = testCoroutineScope.createStringController() - assertFalse(sut.stubEnabled) + val scope = TestScope() + val sut = scope.createStringController() + assertFalse(sut.stubEnabled) (sut as EffectController, List, String>).toStub() assertTrue(sut.stubEnabled) + + scope.cancel() } @Test fun `stub actions are recorded correctly`() { + val scope = TestScope(UnconfinedTestDispatcher()) val expectedActions = listOf( listOf("one"), listOf("two"), listOf("three") ) - val sut = testCoroutineScope.createStringController().apply { toStub() } + val sut = scope.createStringController().apply { toStub() } expectedActions.forEach(sut::dispatch) - assertEquals(expectedActions, sut.toStub().dispatchedActions) + assertEquals(expectedActions, sut.dispatchedActions) + + scope.cancel() } @Test fun `stub set state`() { + val scope = TestScope(UnconfinedTestDispatcher()) val expectedStates = listOf( listOf("one"), listOf("two"), listOf("three") ) - val sut = testCoroutineScope.createStringController().apply { toStub() } - val testFlow = sut.state.testIn(testCoroutineScope) + val sut = scope.createStringController().apply { toStub() } + val states = mutableListOf>() + scope.launch { sut.state.toList(states) } - expectedStates.forEach(sut.toStub()::emitState) + expectedStates.forEach(sut::emitState) - testFlow expect emissions(listOf(initialState) + expectedStates) + assertEquals( + listOf(initialState) + expectedStates, + states + ) + + scope.cancel() } @Test fun `stub state contains latest and following states`() { + val scope = TestScope(UnconfinedTestDispatcher()) val expectedStates = listOf( listOf("one"), listOf("two"), listOf("three") ) - val sut = testCoroutineScope.createStringController().apply { toStub() } + val sut = scope.createStringController().apply { toStub() } sut.toStub().emitState(listOf("something 1")) sut.toStub().emitState(listOf("something 2")) - val testFlow = sut.state.testIn(testCoroutineScope) + val states = mutableListOf>() + scope.launch { sut.state.toList(states) } expectedStates.forEach(sut.toStub()::emitState) - testFlow expect emissions(listOf(listOf("something 2")) + expectedStates) + assertEquals(listOf(listOf("something 2")) + expectedStates, states) + + scope.cancel() } @Test fun `stub action does not trigger state machine`() { - val sut = testCoroutineScope.createStringController().apply { toStub() } + val scope = TestScope(UnconfinedTestDispatcher()) + val sut = scope.createStringController().apply { toStub() } sut.dispatch(listOf("test")) - assertEquals(initialState, sut.currentState) + assertEquals(initialState, sut.state.value) + + scope.cancel() } @Test fun `stub emits effects`() { - val sut = testCoroutineScope.createStringController().apply { toStub() } - val testFlow = sut.effects.testIn(testCoroutineScope) + val scope = TestScope(UnconfinedTestDispatcher()) + val sut = scope.createStringController().apply { toStub() } + val effects = mutableListOf() + scope.launch { sut.effects.toList(effects) } sut.emitEffect("effect1") sut.emitEffect("effect2") - testFlow expect emissions("effect1", "effect2") + assertEquals( + listOf("effect1", "effect2"), + effects + ) + + scope.cancel() } private fun CoroutineScope.createStringController() = diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/TakeUntilTest.kt similarity index 56% rename from control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt rename to control-core/src/test/kotlin/at/florianschuster/control/TakeUntilTest.kt index 773a7f0..f82039f 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/TakeUntilTest.kt @@ -1,46 +1,21 @@ package at.florianschuster.control -import io.mockk.spyk -import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals @ExperimentalCoroutinesApi -internal class ExtensionsTest { +internal class TakeUntilTest { @Test - fun `bind lambda emits values correctly`() = runBlockingTest { - val lambda = spyk<(Int) -> Unit>() - flow { - emit(1) - emit(2) - }.bind(to = lambda).launchIn(this) - - verify(exactly = 2) { lambda.invoke(any()) } - } - - @Test(expected = IllegalStateException::class) - fun `bind lambda throws error`() = runBlockingTest { - flow { error("test") }.bind { }.launchIn(this) - } - - @Test - fun `distinctMap works`() = runBlockingTest { - val result = listOf(0, 1, 1, 2, 2, 3, 4, 4, 5, 5).asFlow().distinctMap { it * 2 }.toList() - assertEquals(listOf(0, 2, 4, 6, 8, 10), result) - } - - @Test - fun `takeUntil with predicate`() = runBlockingTest { + fun `takeUntil with predicate`() = runTest { val numberFlow = (0..10).asFlow() val list = numberFlow.takeUntil { it == 5 }.toList() val inclusiveList = numberFlow.takeUntil(inclusive = true) { it == 5 }.toList() @@ -50,7 +25,7 @@ internal class ExtensionsTest { } @Test - fun `takeUntil with other flow`() = runBlockingTest { + fun `takeUntil with other flow`() = runTest { val numberFlow = (0..10).asFlow().map { delay(100); it } val shortResult = numberFlow.takeUntil(flow { delay(501); emit(Unit) }).toList() diff --git a/examples/android-compose/build.gradle.kts b/examples/android-compose/build.gradle.kts index 775d78d..4e773c7 100644 --- a/examples/android-compose/build.gradle.kts +++ b/examples/android-compose/build.gradle.kts @@ -23,9 +23,7 @@ android { resources.excludes.add("META-INF/LGPL2.1") } buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.1.0-alpha06" - } + composeOptions { kotlinCompilerExtensionVersion = "1.1.1" } kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() } @@ -35,15 +33,16 @@ dependencies { implementation(project(":control-core")) implementation(project(":examples:kotlin-counter")) - implementation("androidx.activity:activity-compose:1.3.1") - implementation("androidx.compose.ui:ui:1.1.0-alpha06") - implementation("androidx.compose.ui:ui-tooling:1.1.0-alpha06") - implementation("androidx.compose.material:material:1.1.0-alpha06") - implementation("androidx.compose.material:material-icons-core:1.1.0-alpha06") - implementation("androidx.compose.material:material-icons-extended:1.1.0-alpha06") + implementation("androidx.activity:activity-compose:1.4.0") + implementation("androidx.compose.ui:ui:1.1.1") + implementation("androidx.compose.ui:ui-tooling:1.1.1") + implementation("androidx.compose.material:material:1.1.1") + implementation("androidx.compose.material:material-icons-core:1.1.1") + implementation("androidx.compose.material:material-icons-extended:1.1.1") - debugImplementation("androidx.compose.ui:ui-test-manifest:1.1.0-alpha06") - androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.1.0-alpha06") - androidTestImplementation("androidx.test.espresso:espresso-core:3.3.0") + debugImplementation("androidx.compose.ui:ui-test-manifest:1.1.1") + androidTestImplementation(kotlin("test")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.1.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") androidTestImplementation("androidx.test.ext:junit-ktx:1.1.3") } diff --git a/examples/android-compose/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterScreenTest.kt b/examples/android-compose/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterScreenTest.kt index e38b5d5..c25ca45 100644 --- a/examples/android-compose/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterScreenTest.kt +++ b/examples/android-compose/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterScreenTest.kt @@ -13,10 +13,11 @@ import at.florianschuster.control.kotlincounter.createCounterController import at.florianschuster.control.toStub import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.TestScope import org.junit.Before import org.junit.Rule import org.junit.Test +import kotlin.test.assertEquals @FlowPreview @ExperimentalCoroutinesApi @@ -29,7 +30,7 @@ internal class CounterScreenTest { @Before fun setup() { - val scope = TestCoroutineScope() + val scope = TestScope() val controller = scope.createCounterController().toStub() composeTestRule.setContent { CounterScreen(scope = scope, controller = controller) @@ -44,7 +45,7 @@ internal class CounterScreenTest { .performClick() // then - assert(CounterAction.Increment == stub.dispatchedActions.last()) + assertEquals(CounterAction.Increment, stub.dispatchedActions.last()) } @Test @@ -54,7 +55,7 @@ internal class CounterScreenTest { .performClick() // then - assert(CounterAction.Decrement == stub.dispatchedActions.last()) + assertEquals(CounterAction.Decrement, stub.dispatchedActions.last()) } @Test diff --git a/examples/android-counter/build.gradle.kts b/examples/android-counter/build.gradle.kts index d7b9455..7c79390 100644 --- a/examples/android-counter/build.gradle.kts +++ b/examples/android-counter/build.gradle.kts @@ -27,18 +27,18 @@ dependencies { implementation(project(":control-core")) implementation(project(":examples:kotlin-counter")) - implementation("androidx.appcompat:appcompat:1.3.1") - implementation("androidx.constraintlayout:constraintlayout:2.1.1") + implementation("androidx.appcompat:appcompat:1.4.1") + implementation("androidx.constraintlayout:constraintlayout:2.1.3") implementation("io.github.reactivecircus.flowbinding:flowbinding-android:1.2.0") implementation("io.github.reactivecircus.flowbinding:flowbinding-core:1.2.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.1") - implementation("androidx.fragment:fragment-ktx:1.3.6") - debugImplementation("androidx.fragment:fragment-testing:1.3.6") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.1") + implementation("androidx.fragment:fragment-ktx:1.4.1") + debugImplementation("androidx.fragment:fragment-testing:1.4.1") + androidTestImplementation(kotlin("test")) androidTestImplementation("androidx.test:rules:1.4.0") androidTestImplementation("androidx.test:runner:1.4.0") androidTestImplementation("androidx.test:core-ktx:1.4.0") - androidTestImplementation("androidx.test.espresso:espresso-core:3.3.0") - androidTestImplementation("at.florianschuster.test:coroutines-test-extensions:0.1.2") + androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") androidTestImplementation("androidx.test.ext:junit-ktx:1.1.3") } diff --git a/examples/android-github/build.gradle.kts b/examples/android-github/build.gradle.kts index 9ce04b5..2be018f 100644 --- a/examples/android-github/build.gradle.kts +++ b/examples/android-github/build.gradle.kts @@ -33,31 +33,33 @@ android { dependencies { implementation(project(":control-core")) - implementation("androidx.appcompat:appcompat:1.3.1") + implementation("androidx.appcompat:appcompat:1.4.1") implementation("io.coil-kt:coil:1.4.0") - implementation("androidx.constraintlayout:constraintlayout:2.1.1") + implementation("androidx.constraintlayout:constraintlayout:2.1.3") implementation("io.github.reactivecircus.flowbinding:flowbinding-android:1.2.0") implementation("io.github.reactivecircus.flowbinding:flowbinding-core:1.2.0") implementation("io.github.reactivecircus.flowbinding:flowbinding-recyclerview:1.2.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.1") - implementation("androidx.fragment:fragment-ktx:1.3.6") - debugImplementation("androidx.fragment:fragment-testing:1.3.6") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.3.0") - implementation("io.ktor:ktor-client-cio:1.6.4") - implementation("io.ktor:ktor-client-json-jvm:1.6.4") - implementation("io.ktor:ktor-client-logging-jvm:1.6.4") - implementation("io.ktor:ktor-client-serialization-jvm:1.6.4") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.1") - implementation("com.google.android.material:material:1.4.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.1") + implementation("androidx.fragment:fragment-ktx:1.4.1") + debugImplementation("androidx.fragment:fragment-testing:1.4.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.3.2") + implementation("io.ktor:ktor-client-cio:2.0.0") + implementation("io.ktor:ktor-client-logging-jvm:2.0.0") + implementation("io.ktor:ktor-client-serialization:2.0.0") + implementation("io.ktor:ktor-client-content-negotiation:2.0.0") + implementation("io.ktor:ktor-serialization-kotlinx-json:2.0.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.1") + implementation("com.google.android.material:material:1.5.0") - testImplementation("at.florianschuster.test:coroutines-test-extensions:0.1.2") - testImplementation("io.mockk:mockk:1.12.0") + testImplementation(kotlin("test")) + testImplementation("io.mockk:mockk:1.12.3") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1") + androidTestImplementation(kotlin("test")) androidTestImplementation("androidx.test:rules:1.4.0") androidTestImplementation("androidx.test:runner:1.4.0") androidTestImplementation("androidx.test:core-ktx:1.4.0") - androidTestImplementation("androidx.test.espresso:espresso-core:3.3.0") - androidTestImplementation("at.florianschuster.test:coroutines-test-extensions:0.1.2") + androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") androidTestImplementation("androidx.test.ext:junit-ktx:1.1.3") - androidTestImplementation("io.mockk:mockk-android:1.12.0") + androidTestImplementation("io.mockk:mockk-android:1.12.3") } diff --git a/examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/SearchViewTest.kt b/examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/SearchViewTest.kt index 26a4142..f083bfd 100644 --- a/examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/SearchViewTest.kt +++ b/examples/android-github/src/androidTest/kotlin/at/florianschuster/control/androidgithub/search/SearchViewTest.kt @@ -24,7 +24,7 @@ import kotlin.test.assertEquals internal class SearchViewTest { - private lateinit var stub: EffectControllerStub + private lateinit var stub: EffectControllerStub @Before fun setup() { @@ -50,7 +50,7 @@ internal class SearchViewTest { // then assertEquals( - SearchViewModel.Action.UpdateQuery(testQuery), + SearchAction.UpdateQuery(testQuery), stub.dispatchedActions.last() ) } @@ -58,13 +58,13 @@ internal class SearchViewTest { @Test fun whenStateOffersLoadingNextPage_ThenProgressBarIsShown() { // when - stub.emitState(SearchViewModel.State(loadingNextPage = true)) + stub.emitState(SearchState(loadingNextPage = true)) // then onView(withId(R.id.loadingProgressBar)).check(matches(isDisplayed())) // when - stub.emitState(SearchViewModel.State(loadingNextPage = false)) + stub.emitState(SearchState(loadingNextPage = false)) // then onView(withId(R.id.loadingProgressBar)).check(matches(not(isDisplayed()))) @@ -73,7 +73,7 @@ internal class SearchViewTest { @Test fun whenNetworkErrorEffect_ThenSnackbarIsShown() { // when - stub.emitEffect(SearchViewModel.Effect.NotifyNetworkError) + stub.emitEffect(SearchEffect.NotifyNetworkError) // then onView(withId(com.google.android.material.R.id.snackbar_text)) 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 0c26d0d..e8c4e24 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 @@ -2,48 +2,51 @@ package at.florianschuster.control.androidgithub import at.florianschuster.control.androidgithub.model.Repository import io.ktor.client.HttpClient +import io.ktor.client.call.body import io.ktor.client.engine.cio.CIO -import io.ktor.client.features.json.JsonFeature -import io.ktor.client.features.json.serializer.KotlinxSerializer -import io.ktor.client.features.logging.LogLevel -import io.ktor.client.features.logging.Logger -import io.ktor.client.features.logging.Logging -import io.ktor.client.features.logging.SIMPLE +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.logging.SIMPLE import io.ktor.client.request.get import io.ktor.client.request.parameter +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json internal class GithubApi( - json: Json = Json { - isLenient = true - ignoreUnknownKeys = true - }, - private val httpClient: HttpClient = HttpClient(engineFactory = CIO) { - install(feature = JsonFeature) { - serializer = KotlinxSerializer(json = json) + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + @Serializable + private data class SearchResponse(val items: List) + + private val httpClient = HttpClient(engineFactory = CIO) { + install(ContentNegotiation) { + json(json = Json { + isLenient = true + ignoreUnknownKeys = true + }) } - install(feature = Logging) { + install(Logging) { logger = Logger.SIMPLE level = LogLevel.BODY } } -) { - @Serializable - private data class SearchResponse(val items: List) suspend fun search( query: String, page: Int - ): List { - val response = httpClient.get( - "https://api.github.com/search/repositories" + ): List = withContext(ioDispatcher) { + val response = httpClient.get( + urlString = "https://api.github.com/search/repositories" ) { - url { - parameter("q", query) - parameter("page", page) - } + parameter("q", query) + parameter("page", page) } - return response.items + response.body().items } } 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 index b384138..9ae57cb 100644 --- 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 @@ -16,11 +16,15 @@ 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 areItemsTheSame( + oldItem: Repository, + newItem: Repository + ): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: Repository, newItem: Repository): Boolean = - oldItem == newItem + override fun areContentsTheSame( + oldItem: Repository, + newItem: Repository + ): Boolean = oldItem == newItem } ) { diff --git a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchView.kt b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchView.kt index 6640bdb..7ba0fcb 100644 --- a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchView.kt +++ b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/search/SearchView.kt @@ -7,22 +7,24 @@ import android.view.View import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle 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 com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.CoroutineScope 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 kotlinx.coroutines.launch import reactivecircus.flowbinding.android.widget.textChanges import reactivecircus.flowbinding.recyclerview.scrollEvents @@ -31,51 +33,57 @@ internal class SearchView : Fragment(R.layout.view_search) { private val binding by viewBinding(ViewSearchBinding::bind) private val viewModel by viewModels { SearchViewModel.Factory } - private val repoAdapter = SearchAdapter { repo -> - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(repo.webUrl))) - } + private val searchAdapter: SearchAdapter? + get() = binding.repoRecyclerView.adapter as? SearchAdapter override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - with(binding.repoRecyclerView) { - adapter = repoAdapter + adapter = SearchAdapter { repo -> + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(repo.webUrl))) + } itemAnimator = null addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)) } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + bindViewModel() + } + } + } + private fun CoroutineScope.bindViewModel() { // action binding.searchEditText.textChanges() .debounce(SearchDebounceMilliseconds) .map { it.toString() } - .map { SearchViewModel.Action.UpdateQuery(it) } - .bind(to = viewModel.controller::dispatch) - .launchIn(scope = viewLifecycleOwner.lifecycleScope) + .map { SearchAction.UpdateQuery(it) } + .onEach { viewModel.controller.dispatch(it) } + .launchIn(scope = this) binding.repoRecyclerView.scrollEvents() .sample(500) .filter { it.view.shouldLoadMore() } - .map { SearchViewModel.Action.LoadNextPage } - .bind(to = viewModel.controller::dispatch) - .launchIn(scope = viewLifecycleOwner.lifecycleScope) + .map { SearchAction.LoadNextPage } + .onEach { viewModel.controller.dispatch(it) } + .launchIn(scope = this) // state - viewModel.controller.state.distinctMap(by = SearchViewModel.State::repos) - .bind(to = repoAdapter::submitList) - .launchIn(scope = viewLifecycleOwner.lifecycleScope) - - viewModel.controller.state.distinctMap(by = SearchViewModel.State::loadingNextPage) - .bind(to = binding.loadingProgressBar::isVisible::set) - .launchIn(scope = viewLifecycleOwner.lifecycleScope) + viewModel.controller.state.onEach { state -> + binding.loadingProgressBar.isVisible = state.loadingNextPage + searchAdapter?.submitList(state.repos) + }.launchIn(scope = this) // effect viewModel.controller.effects.onEach { effect -> when (effect) { - is SearchViewModel.Effect.NotifyNetworkError -> { - binding.root.showSnackBar(R.string.info_network_error) + is SearchEffect.NotifyNetworkError -> { + Snackbar + .make(binding.root, R.string.info_network_error, Snackbar.LENGTH_LONG) + .show() } } - }.launchIn(scope = viewLifecycleOwner.lifecycleScope) + }.launchIn(scope = this) } private fun RecyclerView.shouldLoadMore(threshold: Int = 8): Boolean { 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 99255b1..2e03cb4 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 @@ -6,12 +6,12 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import at.florianschuster.control.ControllerEvent import at.florianschuster.control.ControllerLog +import at.florianschuster.control.EffectController import at.florianschuster.control.androidgithub.GithubApi 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 +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emptyFlow @@ -21,106 +21,114 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map internal class SearchViewModel( - initialState: State = State(), - private val api: GithubApi = GithubApi(), - controllerDispatcher: CoroutineDispatcher = Dispatchers.Default + api: GithubApi = GithubApi(), ) : ViewModel() { - sealed interface Action { - data class UpdateQuery(val text: String) : Action - object LoadNextPage : Action - } + val controller = viewModelScope.createSearchController( + initialState = SearchState(), + api = api + ) - sealed interface Mutation { - data class SetQuery(val query: String) : Mutation - data class SetRepos(val repos: List) : Mutation - data class AppendRepos(val repos: List) : Mutation - data class SetLoadingNextPage(val loading: Boolean) : Mutation + companion object { + internal var Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = SearchViewModel() as T + } } +} - data class State( - val query: String = "", - val repos: List = emptyList(), - val page: Int = 1, - val loadingNextPage: Boolean = false - ) +internal sealed interface SearchAction { + data class UpdateQuery(val text: String) : SearchAction + object LoadNextPage : SearchAction +} - sealed interface Effect { - object NotifyNetworkError : Effect - } +private sealed interface SearchMutation { + data class SetQuery(val query: String) : SearchMutation + data class SetRepos(val repos: List) : SearchMutation + data class AppendRepos(val repos: List) : SearchMutation + data class SetLoadingNextPage(val loading: Boolean) : SearchMutation +} - val controller = viewModelScope.createEffectController( - initialState = initialState, - - mutator = { action -> - when (action) { - is Action.UpdateQuery -> flow { - emit(Mutation.SetQuery(action.text)) - if (action.text.isNotEmpty()) { - emit(Mutation.SetLoadingNextPage(true)) - - // flow search - emitAll( - flow { emit(api.search(currentState.query, 1)) } - .catch { error -> - emitEffect(Effect.NotifyNetworkError) - Log.w("GithubViewModel", error) - emit(emptyList()) - } - .filter { it.isNotEmpty() } - .map { Mutation.SetRepos(it) } - .takeUntil(actions.filterIsInstance()) - ) - - emit(Mutation.SetLoadingNextPage(false)) - } +internal data class SearchState( + val query: String = "", + val repos: List = emptyList(), + val page: Int = 1, + val loadingNextPage: Boolean = false +) + +internal sealed interface SearchEffect { + object NotifyNetworkError : SearchEffect +} + +internal fun CoroutineScope.createSearchController( + initialState: SearchState, + api: GithubApi +): EffectController = createEffectController( + initialState = initialState, + + mutator = { action -> + when (action) { + is SearchAction.UpdateQuery -> flow { + emit(SearchMutation.SetQuery(action.text)) + + if (action.text.isNotEmpty()) { + emit(SearchMutation.SetLoadingNextPage(true)) + + emitAll( + flow { emit(api.search(action.text, 1)) } + .catch { error -> + emitEffect(SearchEffect.NotifyNetworkError) + Log.w("GithubViewModel", error) + emit(emptyList()) + } + .filter { repos -> repos.isNotEmpty() } + .map { repos -> SearchMutation.SetRepos(repos) } + .takeUntil(actions.filterIsInstance()) + ) + + emit(SearchMutation.SetLoadingNextPage(false)) } - is Action.LoadNextPage -> when { - currentState.loadingNextPage -> emptyFlow() - else -> flow { - val state = currentState - emit(Mutation.SetLoadingNextPage(true)) - - // suspending search - val repos = kotlin.runCatching { - api.search(state.query, state.page + 1) - }.getOrElse { error -> - emitEffect(Effect.NotifyNetworkError) - Log.w("GithubViewModel", error) - emptyList() - } - emit(Mutation.AppendRepos(repos)) - - emit(Mutation.SetLoadingNextPage(false)) + } + is SearchAction.LoadNextPage -> when { + currentState.loadingNextPage -> emptyFlow() + else -> flow { + emit(SearchMutation.SetLoadingNextPage(true)) + + val repos = kotlin.runCatching { + api.search(currentState.query, currentState.page + 1) + }.getOrElse { error -> + emitEffect(SearchEffect.NotifyNetworkError) + Log.w("GithubViewModel", error) + emptyList() } + emit(SearchMutation.AppendRepos(repos)) + + emit(SearchMutation.SetLoadingNextPage(false)) } } - }, - - reducer = { mutation, previousState -> - when (mutation) { - is Mutation.SetQuery -> previousState.copy(query = mutation.query) - is Mutation.SetRepos -> previousState.copy(repos = mutation.repos, page = 1) - is Mutation.AppendRepos -> previousState.copy( - repos = previousState.repos + mutation.repos, - page = previousState.page + 1 - ) - is Mutation.SetLoadingNextPage -> previousState.copy(loadingNextPage = mutation.loading) - } - }, - - // viewModelScope uses Dispatchers.Main, we do not want to run on Main - dispatcher = controllerDispatcher, - - controllerLog = ControllerLog.Custom { message -> - 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 + }, + + reducer = { mutation, previousState -> + when (mutation) { + is SearchMutation.SetQuery -> previousState.copy( + query = mutation.query + ) + is SearchMutation.SetRepos -> previousState.copy( + repos = mutation.repos, + page = 1 + ) + is SearchMutation.AppendRepos -> previousState.copy( + repos = previousState.repos + mutation.repos, + page = previousState.page + 1 + ) + is SearchMutation.SetLoadingNextPage -> previousState.copy( + loadingNextPage = mutation.loading + ) } + }, + + controllerLog = ControllerLog.Custom { message -> + if (event is ControllerEvent.State) Log.d("GithubViewModel", message) } -} +) 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 deleted file mode 100644 index 2f5219d..0000000 --- a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/snackbar.kt +++ /dev/null @@ -1,22 +0,0 @@ -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/test/kotlin/at/florianschuster/control/androidgithub/search/SearchControllerTest.kt b/examples/android-github/src/test/kotlin/at/florianschuster/control/androidgithub/search/SearchControllerTest.kt new file mode 100644 index 0000000..842411a --- /dev/null +++ b/examples/android-github/src/test/kotlin/at/florianschuster/control/androidgithub/search/SearchControllerTest.kt @@ -0,0 +1,194 @@ +package at.florianschuster.control.androidgithub.search + +import at.florianschuster.control.EffectController +import at.florianschuster.control.androidgithub.GithubApi +import at.florianschuster.control.androidgithub.model.Repository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +@FlowPreview +@ExperimentalCoroutinesApi +internal class SearchControllerTest { + + private lateinit var githubApi: GithubApi + private lateinit var sut: EffectController + private lateinit var states: List + private lateinit var effects: List + + private fun CoroutineScope.givenControllerIsCreated( + initialState: SearchState = SearchState() + ) { + githubApi = mockk { + coEvery { search(any(), 1) } returns MockReposPage1 + coEvery { search(any(), 2) } returns MockReposPage2 + } + sut = createSearchController(initialState, githubApi) + states = sut.state.testIn(this) + effects = sut.effects.testIn(this) + } + + private fun runTestAndCleanup( + body: TestScope.() -> Unit + ) = runTest(UnconfinedTestDispatcher()) { + try { + body() + } finally { + coroutineContext.cancelChildren() // cancel states/effects + } + } + + @Test + fun `UpdateQuery - with non-empty text`() = runTestAndCleanup { + // given + givenControllerIsCreated() + + // when + sut.dispatch(SearchAction.UpdateQuery(MockQuery)) + + // then + coVerify(exactly = 1) { githubApi.search(MockQuery, 1) } + assertEquals(listOf(false, true, false), states.map(SearchState::loadingNextPage)) + assertEquals( + SearchState( + query = MockQuery, + repos = MockReposPage1, + page = 1, + loadingNextPage = false + ), + states.last() + ) + } + + @Test + fun `UpdateQuery - with empty text`() = runTestAndCleanup { + // given + val emptyQuery = "" + givenControllerIsCreated() + + // when + sut.dispatch(SearchAction.UpdateQuery(emptyQuery)) + + // then + coVerify(exactly = 0) { githubApi.search(any(), any()) } + assertEquals(states.last(), SearchState(query = emptyQuery)) + } + + @Test + fun `LoadNextPage - loads correct next page`() = runTestAndCleanup { + // given + givenControllerIsCreated( + SearchState(query = MockQuery, repos = MockReposPage1) + ) + + // when + sut.dispatch(SearchAction.LoadNextPage) + + // then + coVerify(exactly = 1) { githubApi.search(any(), 2) } + with(sut.state.value) { + assertEquals(MockQuery, query) + assertEquals(MockReposPage1 + MockReposPage2, repos) + assertFalse(loadingNextPage) + } + } + + @Test + fun `LoadNextPage - only when currently not loading`() = runTestAndCleanup { + // given + val initialState = SearchState(loadingNextPage = true) + givenControllerIsCreated(initialState) + + // when + sut.dispatch(SearchAction.LoadNextPage) + + // then + coVerify(exactly = 0) { githubApi.search(any(), any()) } + assertEquals(listOf(initialState), states) + } + + @Test + fun `UpdateQuery - exception from api is correctly handled`() = + runTestAndCleanup { + // given + givenControllerIsCreated() + coEvery { githubApi.search(any(), any()) } throws IllegalStateException() + + // when + sut.dispatch(SearchAction.UpdateQuery(MockQuery)) + + // then + coVerify(exactly = 1) { githubApi.search(MockQuery, 1) } + assertEquals( + SearchState(query = MockQuery, loadingNextPage = false), + states.last() + ) + assertEquals(SearchEffect.NotifyNetworkError, effects.single()) + } + + @Test + fun `UpdateQuery - search resets previous`() = runTestAndCleanup { + // given + givenControllerIsCreated() + coEvery { githubApi.search(MockQuery, 1) } coAnswers { + delay(1000) + MockReposPage1 + } + coEvery { githubApi.search(MockSecondQuery, 1) } coAnswers { + delay(1000) + MockReposPage2 + } + + // when + sut.dispatch(SearchAction.UpdateQuery(MockQuery)) + advanceTimeBy(500) // updated before last query can finish + sut.dispatch(SearchAction.UpdateQuery(MockSecondQuery)) + advanceUntilIdle() + + // then + coVerifyOrder { + githubApi.search(MockQuery, 1) + githubApi.search(MockSecondQuery, 1) + } + assertFalse(states.any { it.repos == MockReposPage1 }) + assertEquals( + SearchState(query = MockSecondQuery, repos = MockReposPage2), + states.last() + ) + } + + companion object { + 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 MockQuery = "control" + private const val MockSecondQuery = "controlAgain" + } +} + +private fun Flow.testIn(scope: CoroutineScope): List { + val emissions = mutableListOf() + scope.launch { toList(emissions) } + return emissions +} 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 deleted file mode 100644 index f07df57..0000000 --- a/examples/android-github/src/test/kotlin/at/florianschuster/control/androidgithub/search/SearchViewModelTest.kt +++ /dev/null @@ -1,176 +0,0 @@ -package at.florianschuster.control.androidgithub.search - -import at.florianschuster.control.androidgithub.GithubApi -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 -import at.florianschuster.test.flow.emissions -import at.florianschuster.test.flow.expect -import at.florianschuster.test.flow.lastEmission -import at.florianschuster.test.flow.testIn -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.coVerifyOrder -import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.delay -import org.junit.Rule -import org.junit.Test -import java.io.IOException -import kotlin.test.assertFalse - -@FlowPreview -@ExperimentalCoroutinesApi -internal class SearchViewModelTest { - - @get:Rule - val testCoroutineScope = TestCoroutineScopeRule() - - private val githubApi: GithubApi = mockk { - coEvery { search(any(), 1) } returns mockReposPage1 - coEvery { search(any(), 2) } returns mockReposPage2 - } - private lateinit var sut: SearchViewModel - private lateinit var states: TestFlow - private lateinit var effects: TestFlow - - private fun `given ViewModel is created`( - initialState: SearchViewModel.State = SearchViewModel.State() - ) { - 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 ViewModel is created`() - - // when - sut.controller.dispatch(SearchViewModel.Action.UpdateQuery(query)) - - // then - coVerify(exactly = 1) { githubApi.search(query, 1) } - states expect emissions( - SearchViewModel.State(), - SearchViewModel.State(query = query), - SearchViewModel.State(query = query, loadingNextPage = true), - SearchViewModel.State(query, mockReposPage1, 1, true), - SearchViewModel.State(query, mockReposPage1, 1, false) - ) - } - - @Test - fun `update query with empty text`() { - // given - val emptyQuery = "" - `given ViewModel is created`() - - // when - sut.controller.dispatch(SearchViewModel.Action.UpdateQuery(emptyQuery)) - - // then - coVerify(exactly = 0) { githubApi.search(any(), any()) } - states expect lastEmission(SearchViewModel.State(query = emptyQuery)) - } - - @Test - fun `load next page loads correct next page`() { - // given - `given ViewModel is created`( - SearchViewModel.State(query = query, repos = mockReposPage1) - ) - - // when - sut.controller.dispatch(SearchViewModel.Action.LoadNextPage) - - // then - coVerify(exactly = 1) { githubApi.search(any(), 2) } - states expect emissions( - 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 = SearchViewModel.State(loadingNextPage = true) - `given ViewModel is created`(initialState) - - // when - sut.controller.dispatch(SearchViewModel.Action.LoadNextPage) - - // then - coVerify(exactly = 0) { githubApi.search(any(), any()) } - states expect emissionCount(1) - states expect emissions(initialState) - } - - @Test - fun `empty list from github api is correctly handled`() { - // given - coEvery { githubApi.search(any(), any()) } throws IOException() - `given ViewModel is created`() - - // when - sut.controller.dispatch(SearchViewModel.Action.UpdateQuery(query)) - - // then - coVerify(exactly = 1) { githubApi.search(query, 1) } - states expect emissions( - SearchViewModel.State(), - SearchViewModel.State(query = query), - SearchViewModel.State(query = query, loadingNextPage = true), - SearchViewModel.State(query = query, loadingNextPage = false) - ) - effects expect emissions(SearchViewModel.Effect.NotifyNetworkError) - } - - @Test - fun `updating query during search resets search`() { - // given - coEvery { githubApi.search(query, 1) } coAnswers { - delay(1000) - mockReposPage1 - } - coEvery { githubApi.search(secondQuery, 1) } coAnswers { - delay(1000) - mockReposPage2 - } - `given ViewModel is created`() - - // when - sut.controller.dispatch(SearchViewModel.Action.UpdateQuery(query)) - testCoroutineScope.advanceTimeBy(500) // updated before last query can finish - sut.controller.dispatch(SearchViewModel.Action.UpdateQuery(secondQuery)) - testCoroutineScope.advanceUntilIdle() - - // then - coVerifyOrder { - githubApi.search(query, 1) - githubApi.search(secondQuery, 1) - } - assertFalse(states.emissions.any { it.repos == mockReposPage1 }) - states expect lastEmission( - SearchViewModel.State(query = secondQuery, repos = mockReposPage2) - ) - } - - companion object { - 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/examples/kotlin-counter/build.gradle.kts b/examples/kotlin-counter/build.gradle.kts index 804cfbf..e1ea5d5 100644 --- a/examples/kotlin-counter/build.gradle.kts +++ b/examples/kotlin-counter/build.gradle.kts @@ -4,5 +4,7 @@ plugins { dependencies { implementation(project(":control-core")) - testImplementation("at.florianschuster.test:coroutines-test-extensions:0.1.2") + + testImplementation(kotlin("test")) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1") } diff --git a/examples/kotlin-counter/src/main/kotlin/at/florianschuster/control/kotlincounter/CounterController.kt b/examples/kotlin-counter/src/main/kotlin/at/florianschuster/control/kotlincounter/CounterController.kt index d098835..b4d78bf 100644 --- a/examples/kotlin-counter/src/main/kotlin/at/florianschuster/control/kotlincounter/CounterController.kt +++ b/examples/kotlin-counter/src/main/kotlin/at/florianschuster/control/kotlincounter/CounterController.kt @@ -37,10 +37,12 @@ data class CounterState( /** * creates a [CounterController] from the [CoroutineScope]. */ -fun CoroutineScope.createCounterController(): CounterController = createController( +fun CoroutineScope.createCounterController( + initialValue: Int = 0 +): CounterController = createController( // we start with the initial state - initialState = CounterState(value = 0, loading = false), + initialState = CounterState(value = initialValue, loading = false), // every action is transformed into [0..n] mutations mutator = { action -> diff --git a/examples/kotlin-counter/src/test/kotlin/at/florianschuster/control/kotlincounter/CounterControllerTest.kt b/examples/kotlin-counter/src/test/kotlin/at/florianschuster/control/kotlincounter/CounterControllerTest.kt index 4e7fc75..c4da156 100644 --- a/examples/kotlin-counter/src/test/kotlin/at/florianschuster/control/kotlincounter/CounterControllerTest.kt +++ b/examples/kotlin-counter/src/test/kotlin/at/florianschuster/control/kotlincounter/CounterControllerTest.kt @@ -1,14 +1,14 @@ package at.florianschuster.control.kotlincounter -import at.florianschuster.test.coroutines.TestCoroutineScopeRule -import at.florianschuster.test.flow.TestFlow -import at.florianschuster.test.flow.emissionCount -import at.florianschuster.test.flow.emissions -import at.florianschuster.test.flow.expect -import at.florianschuster.test.flow.testIn import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import org.junit.Rule +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals @@ -16,40 +16,35 @@ import kotlin.test.assertEquals @ExperimentalCoroutinesApi internal class CounterControllerTest { - @get:Rule - val testCoroutineScope = TestCoroutineScopeRule() - - private lateinit var controller: CounterController - private lateinit var states: TestFlow - - private fun `given counter controller`() { - controller = testCoroutineScope.createCounterController() - states = controller.state.testIn(testCoroutineScope) - } - @Test - fun `action increment triggers correct flow`() { + fun `action increment triggers correct flow`() = runTest(UnconfinedTestDispatcher()) { // given - `given counter controller`() + val controller = createCounterController(initialValue = 0) + val states = mutableListOf() + launch { controller.state.toList(states) } // when controller.dispatch(CounterAction.Increment) - testCoroutineScope.advanceTimeBy(1000) + advanceUntilIdle() // then - states expect emissionCount(4) - states expect emissions( - CounterState(0, false), - CounterState(0, true), - CounterState(1, true), - CounterState(1, false) + assertEquals( + listOf( + CounterState(0, false), + CounterState(0, true), + CounterState(1, true), + CounterState(1, false) + ), + states ) + + coroutineContext.cancelChildren() } @Test - fun `actions trigger correct current state`() { + fun `actions trigger correct state`() = runTest(UnconfinedTestDispatcher()) { // given - `given counter controller`() + val controller = createCounterController(initialValue = 0) // when controller.dispatch(CounterAction.Increment) @@ -58,9 +53,11 @@ internal class CounterControllerTest { controller.dispatch(CounterAction.Decrement) controller.dispatch(CounterAction.Decrement) - testCoroutineScope.advanceTimeBy(5000) + advanceUntilIdle() // then assertEquals(CounterState(-1, false), controller.state.value) + + coroutineContext.cancelChildren() } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c4600fb..c473012 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-7.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip