diff --git a/.idea/misc.xml b/.idea/misc.xml index 7bfef59d..d5d35ec4 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,6 @@ - + diff --git a/.media/controller_flow.drawio b/.media/controller_flow.drawio index 4a11e1d7..bd102c7b 100644 --- a/.media/controller_flow.drawio +++ b/.media/controller_flow.drawio @@ -1 +1 @@ -7ZpJk5s4FMc/jY/pQggwPqYXzxwylVR1VSY5TckgQBOMXELe5tOPBGIRwo67gSaVal+MnhaQfu//tMACPmxPfzC0S/6iIU4XthWeFvBxYdsA+J74k5ZzafGAXxpiRkJVqDE8k/+wMlrKuichzrWCnNKUk51uDGiW4YBrNsQYPerFIprqd92hGBuG5wClpvVvEvKktPr2srH/iUmcVHcG3qrM2aDgR8zoPlP3y2iGy5wtqppRfcwTFNJjywSfFvCBUcrLq+3pAadyWKsRK+utL+TWj8xwxm+pQED42SVn5Lrrzeafk42/fvn2Aahmcn6uxgKHYmhUkjKe0JhmKH1qrPdFf7FsFohUU+YTpTtl/Bdzflac0Z5TYUr4NlW5+ET4N3Ft3bkq9V2m1PXjqZ04V4mMs3Orkkx+b+c11YpUVS/njP6oqYpRvy97LLt5cSSrUaF7FuBrw6d8FbEY8yvlYM1bSAjTLRZPKOoxnCJODvpzIOXLcV2ugSouFNeXMC7bPaB0r+50IPhocNepRiRNH2hKWZEJI99fWSthj1OU59XQVh4tEwHdkkBdxwyFRAxmq4Fg424QrnlUOYVemgqPhAl5E5qJLIxyLh+Eau1E4qc70zEhHD/vUAHqKOJTjfiAGcen65BNKKqCr2SrIpq9UuljEx9AJe2kFRs8ayKMtoHxJwhbY9QDZLnxImybpEHoB6E/EJQqndFPaIOvgdJcqO1bbXcagaa91HGCpYmzRt7GCcFkPC2D30tCrzVm6K1ypg69uk+Vch43IsMbI7I3Z0SGhpRRKabrgu4TUAvtCDKBLtRl4rm3RT04lUqAGeVmW6C8TiVgzgWKc6McLrjF28jBgbMwrnlpMa2Bd5EXYvyj3HQ0E11hWxPZ7RZTI86ZqDurm3Xxm8cFlgNdoKgqxgWdWwV2lGQ8b7X8RRpaAcfS52VHbazWN5aHS7vjfuUTNM5Yd2WAfxrhWvDmeFi0NlzEKn7TxHHHdeaO46v31c74qx3vjbQ9CL1nyGe7F/oRgzKigFQg7m5bo+hS5J1ws9FVn+3MvXesjuPe1Tem+pY3qs+ZU31LQ30Mh/sAT6G+CbQDHXtu7QxbnQ7VTks5jY5+oh2weP2KNpBxUUZDbVELeiU2opb8G7U0677dNxeCmB1IMHAp2D2d65/L6gO7/onv6kZikCQ9t3MUOvt0ZpsHKDkJsfTFKJLvh7o4ROf5C1cPyoRSEstDzkAMloiZ8F4OJQlQ+lFlbEkYFpLvg6yHgRFYuI6vs3BNFsseFPZkKMzNUUjyHeJB8vtSsD2gUQCVQ7YoOG9KwVxjBzRNf2spdCG4ljcvBGhGpWDPiu5IAn0nBsNhpDjiw1Dc9ApmwukFVtyqswpgnjm/LUdzmrfu7jJhKfathGb5O8gekI5tayChtTJA9r1imw7kygCZyRff72K8wlAPqo7lz8vQMdcXgeg+k7ObuYX9NSa310AsDycYLQOMsH1w3JHmyc6XDKDnS4aR4qtINt8zlafvzfdi8Ol/ \ No newline at end of file +7ZpLj5s8FIZ/TZYdYQyELDuXtItWrTRSv3b1yQFD3BIcGefWX18bTLAxkzJDmETVZBHhY5vLec57fIEJvFvtPzC0Xn6mMc4mrhPvJ/B+4rpTEIp/aTgog+NVhpSRuDKBxvBIfmNldJR1Q2JcGA05pRkna9MY0TzHETdsiDG6M5slNDOvukYptgyPEcps638k5svKGrrTxv4Rk3RZXxkEs6pmgaJfKaObXF0vpzmualaoPo16xmKJYrrTTPBhAu8Ypbw6Wu3vcCa9Wnus6jd/ovZ4ywznvE8HAuIvPjkg358vFv/vXfzt6/d3QJ2m4IfaFzgWrlFFyviSpjRH2UNjvS2fF8vTAlFq2nyidK2MPzHnB8UZbTgVpiVfZaoW7wn/Lo6dG1+VfsiSOr7f64VDXcg5O2idZPGHXtd0K0t1v4Iz+utIVXj91nZc7QS6YRE+5S0VmoilmJ9oB6t20pPaBRSWD5iusLhD0YDhDHGyNYMQqVhOj+0aqOJAcX0O4+q8W5Rt1JW2BO8s7ibVhGTZHc0oKythEoYzZybsaYaKonZtHdGyENEVidRxylBMhHe1E0QLf4HwkUddU+ql6XBPmJA3obmowqjg8kaocZ5E/Mxg2i0Jx49rVJLbifR0CvEWM473J6Go2lDJVmU0d6bKuyY/gFraSy03BM5IGF0L418Qaj7qADJdBAl2bdIgDqM4HAhKtc7pJ7TAp0AZIaTHlh5OZ6ApRiQDJ5jaOI/IdZwQjMbTsfg9J/U650y9dc3YqdeMqUrOgzIy7JmRg6vKyNCSMqrEdFrQXQLS0J5BJtCHpkwCv1/Wg2OpBNhZ7mITlJepBLziBMXrKQdwVXLw4EUYH3kZOa2B9yQvxPh7uehoBrrSNifysTWmVp6zUbdmN/Py9yohMD13CJRdhV/QQWuwpiTnhXbmr9KgJRzHHJc9tbCa92wPp24r/Ko7aILx+CgD4tNK14I3x8OytRUiTvkbJ497vnfpPD57m+0Mnu0El9L2IPSBJZ/VRuhHOOWMAlKJuL1sTZKnMu+Ii422+lzv0mvHen/uTX0D1DftqT7vqtQ3tdTHcLyJ8BjqG0E70HMvrZ1hs9Oh2tGU0+joL9oBk5fPaCOZF2U2NCa1oFNiL9dS2FNL17VuD+2JIGZbIp7UnW8KHKFi4KSwvU/XPaodt+66h8DnLin6izPwW5uiFx/YXHsrpSAxliGTJPJNURuHeFL+zHmEMqGMpHK7MxL+E9kT3kq/kQhl71XFisRxKf4uyGZCOAML3wtNFr7NYtqBwh0Nhb1MikmxRjxa/rsU3AAYFEAdkBoF71Up2LPtiGbZPy2FNgTfCS4LAdpZac3wltBNUSLo2jwYTiPDCR/GotfbmBHHF1iDq7ctgL39/Log7RHfubnJhaVcwhKaF28gO0B6rmuAhM7MAtn1tm08kDMLZC7fgb+J8QRDM6t6TnhZhp49wYjE4zM5vNmr2esY3V4CsdqnYLRKMML2zvPPNFC2PmoAHR81nCm/imLzaVO1Ed98OQYf/gA= \ No newline at end of file diff --git a/.media/controller_flow.png b/.media/controller_flow.png index 914b61f3..a342377c 100644 Binary files a/.media/controller_flow.png and b/.media/controller_flow.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e36e9e5..0ac409ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,15 @@ # changelog -## `[X.X.X]` - Unreleased +## `[1.0.0]` - Unreleased +- binary compatibility will now be verified on every release. + +## `[0.11.0]` - 2020-05-30 + +- `CoroutineScope.createController` and `CoroutineScope.createSynchronousController` now accept a custom `ControllerStart` parameter instead of `CoroutineStart`. +- Add `ManagedController`. +- `Controller.stub` is now marked as `@TestOnly`. - binary compatibility is now verified on each `[build]` & `[publish]`. -- `Controller.stub` is now marked as `@TestOnly` ## `[0.10.0]` - 2020-05-11 @@ -12,5 +18,5 @@ ## `[0.9.0]` - 2020-05-10 -- `ControllerImplemenation` now uses `MutableStateFlow` instead of `ConflatedBroadCastChannel` internally. +- `ControllerImplementation` now uses `MutableStateFlow` instead of `ConflatedBroadCastChannel` internally. - `Controller.state` emissions are now distinct by default (via `StateFlow`). diff --git a/buildSrc/src/main/kotlin/Libs.kt b/buildSrc/src/main/kotlin/Libs.kt index 92e630a6..cdc82b21 100644 --- a/buildSrc/src/main/kotlin/Libs.kt +++ b/buildSrc/src/main/kotlin/Libs.kt @@ -23,14 +23,14 @@ object Libs { /** * https://github.com/reactivecircus/FlowBinding */ - const val flowbinding_android: String = - "io.github.reactivecircus.flowbinding:flowbinding-android:" + + const val flowbinding_core: String = "io.github.reactivecircus.flowbinding:flowbinding-core:" + Versions.io_github_reactivecircus_flowbinding /** * https://github.com/reactivecircus/FlowBinding */ - const val flowbinding_core: String = "io.github.reactivecircus.flowbinding:flowbinding-core:" + + const val flowbinding_android: String = + "io.github.reactivecircus.flowbinding:flowbinding-android:" + Versions.io_github_reactivecircus_flowbinding /** diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 018892b2..7279262d 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -25,11 +25,11 @@ object Versions { const val androidx_test: String = "1.2.0" - const val androidx_ui: String = "0.1.0-dev11" + const val androidx_ui: String = "0.1.0-dev12" const val io_ktor: String = "1.3.2" // available: "1.3.2-1.4-M1-release-99" - const val com_android_tools_build_gradle: String = "4.1.0-alpha09" + const val com_android_tools_build_gradle: String = "4.1.0-alpha10" const val org_jlleitschuh_gradle_ktlint_gradle_plugin: String = "9.2.1" @@ -47,13 +47,13 @@ object Versions { const val gradle_pitest_plugin: String = "1.5.1" - const val compose_compiler: String = "0.1.0-dev11" + const val compose_compiler: String = "0.1.0-dev12" const val constraintlayout: String = "2.0.0-beta4" const val espresso_core: String = "3.2.0" - const val lint_gradle: String = "27.1.0-alpha09" + const val lint_gradle: String = "27.1.0-alpha10" const val appcompat: String = "1.1.0" @@ -65,12 +65,12 @@ object Versions { const val ktlint: String = "0.36.0" - const val aapt2: String = "4.1.0-alpha09-6422342" + const val aapt2: String = "4.1.0-alpha10-6481518" const val mockk: String = "1.10.0" /** - * Current version: "6.4.1" + * Current version: "6.5-milestone-1" * See issue 19: How to update Gradle itself? * https://github.com/jmfayard/buildSrcVersions/issues/19 */ diff --git a/control-core/api/control-core.api b/control-core/api/control-core.api index 84340276..2068d064 100644 --- a/control-core/api/control-core.api +++ b/control-core/api/control-core.api @@ -1,10 +1,3 @@ -public final class at/florianschuster/control/BuildersKt { - public static final fun createController (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineStart;Lkotlinx/coroutines/CoroutineDispatcher;)Lat/florianschuster/control/Controller; - public static synthetic fun createController$default (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineStart;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/Controller; - public static final fun createSynchronousController (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineStart;Lkotlinx/coroutines/CoroutineDispatcher;)Lat/florianschuster/control/Controller; - public static synthetic fun createSynchronousController$default (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineStart;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/Controller; -} - public abstract interface class at/florianschuster/control/Controller { public abstract fun dispatch (Ljava/lang/Object;)V public abstract fun getCurrentState ()Ljava/lang/Object; @@ -40,6 +33,13 @@ public final class at/florianschuster/control/ControllerEvent$State : at/florian public final class at/florianschuster/control/ControllerEvent$Stub : at/florianschuster/control/ControllerEvent { } +public final class at/florianschuster/control/ControllerKt { + public static final fun createController (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lat/florianschuster/control/ControllerStart;Lkotlinx/coroutines/CoroutineDispatcher;)Lat/florianschuster/control/Controller; + public static synthetic fun createController$default (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lat/florianschuster/control/ControllerStart;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/Controller; + public static final fun createSynchronousController (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lat/florianschuster/control/ControllerStart;Lkotlinx/coroutines/CoroutineDispatcher;)Lat/florianschuster/control/Controller; + public static synthetic fun createSynchronousController$default (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lat/florianschuster/control/ControllerStart;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/Controller; +} + public abstract class at/florianschuster/control/ControllerLog { public static final field Companion Lat/florianschuster/control/ControllerLog$Companion; } @@ -61,6 +61,17 @@ public final class at/florianschuster/control/ControllerLog$Println : at/florian public static final field INSTANCE Lat/florianschuster/control/ControllerLog$Println; } +public abstract class at/florianschuster/control/ControllerStart { +} + +public final class at/florianschuster/control/ControllerStart$Immediately : at/florianschuster/control/ControllerStart { + public static final field INSTANCE Lat/florianschuster/control/ControllerStart$Immediately; +} + +public final class at/florianschuster/control/ControllerStart$Lazy : at/florianschuster/control/ControllerStart { + public static final field INSTANCE Lat/florianschuster/control/ControllerStart$Lazy; +} + public abstract interface class at/florianschuster/control/ControllerStub { public abstract fun emitState (Ljava/lang/Object;)V public abstract fun getDispatchedActions ()Ljava/util/List; @@ -74,11 +85,21 @@ public final class at/florianschuster/control/ExtensionsKt { 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/LoggerScope { +public abstract interface class at/florianschuster/control/LoggerContext { public abstract fun getEvent ()Lat/florianschuster/control/ControllerEvent; } -public abstract interface class at/florianschuster/control/MutatorScope { +public abstract interface class at/florianschuster/control/ManagedController : at/florianschuster/control/Controller { + public abstract fun cancel ()Ljava/lang/Object; + public abstract fun start ()Z +} + +public final class at/florianschuster/control/ManagedControllerKt { + public static final fun ManagedController (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineDispatcher;)Lat/florianschuster/control/ManagedController; + public static synthetic fun ManagedController$default (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/ManagedController; +} + +public abstract interface class at/florianschuster/control/MutatorContext { public abstract fun getActions ()Lkotlinx/coroutines/flow/Flow; public abstract fun getCurrentState ()Ljava/lang/Object; } diff --git a/control-core/build.gradle.kts b/control-core/build.gradle.kts index 4f4b8de1..698816e5 100644 --- a/control-core/build.gradle.kts +++ b/control-core/build.gradle.kts @@ -43,8 +43,8 @@ pitest { "at.florianschuster.control.ExtensionsKt**", // too many inline collects // inlined invokeSuspend - "at.florianschuster.control.ControllerImplementation\$1\$2", - "at.florianschuster.control.ControllerImplementation\$1", + "at.florianschuster.control.ControllerImplementation\$stateJob\$1", + "at.florianschuster.control.ControllerImplementation\$stateJob\$1\$2", // lateinit var isInitialized "at.florianschuster.control.ControllerImplementation\$stubInitialized\$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 77f0d593..70fc9cfb 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt @@ -1,41 +1,40 @@ 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 +import kotlinx.coroutines.flow.flowOf +import kotlin.coroutines.ContinuationInterceptor /** - * A [Controller] is an ui-independent class that controls the state of a view. The role of a + * A [Controller] is an UI-independent class that controls the state of a view. The role of a * [Controller] is to separate business-logic from view-logic. A [Controller] has no dependency * to the view, so it can easily be unit tested. * * - *
- *                  [Action] via [dispatch]
- *          +-----------------------------------+
- *          |                                   |
- *     +----+-----+                    +--------|-------+
- *     |          |                    |        v       |
- *     |   View   |                    |   Controller   |
- *     |    ^     |                    |                |
- *     +----|-----+                    +--------+-------+
- *          |                                   |
- *          +-----------------------------------+
- *                  [State] via [state]
- * 
+ * ``` + * dispatch(Action) + * ┌───────────────────────────────────┐ + * │ │ + * │ │ + * ┏━━━━━━━━━━┓ ┏━━━━━━━━▼━━━━━━━┓ + * ┃ ┃ ┃ ┃ + * ┃ View ┃ ┃ Controller ┃ + * ┃ ┃ ┃ ┃ + * ┗━━━━▲━━━━━┛ ┗━━━━━━━━━━━━━━━━┛ + * │ │ + * │ │ + * └───────────────────────────────────┘ + * state + * ``` * * The [Controller] creates an uni-directional stream of data as shown in the diagram above, by * handling incoming [Action]'s via [Controller.dispatch] and creating new [State]'s that * can be collected via [Controller.state]. - * - * Basic Principle: 1 [Action] -> [0..n] [Mutation] -> each 1 new [State] - * - * For implementation details look into: - * 1. [Mutator]: [Action] -> [Mutation] - * 2. [Reducer]: [Mutation] -> [State] - * 3. [Transformer] - * 4. [ControllerImplementation] - * - * To create a [Controller] use [CoroutineScope.createController]. */ interface Controller { @@ -55,6 +54,185 @@ interface Controller { val state: Flow } +/** + * Creates a [Controller] bound to the [CoroutineScope] via [ControllerImplementation]. + * If the [CoroutineScope] is cancelled, the internal state machine of the [Controller] completes. + * + * The principle of the created state machine is: + * + * 1 [Action] -> [0..n] [Mutation] -> each 1 new [State] + * + * ``` + * Action + * ┏━━━━━━━━━━━━━━━━━━━━━│━━━━━━━━━━━━━━━━━┓ + * ┃ │ ┃ + * ┃ │ ┃ + * ┃ │ ┃ + * ┃ ┏━━━━━▼━━━━━┓ ┃ side effect ┏━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ┃ mutator ◀───────────────────────────────▶ service/usecase ┃ + * ┃ ┗━━━━━━━━━━━┛ ┃ ┗━━━━━━━━━━━━━━━━━━━━┛ + * ┃ │ ┃ + * ┃ │ 0..n mutations ┃ + * ┃ │ ┃ + * ┃ ┏━━━━━▼━━━━━┓ ┃ + * ┃ ┌───────────▶┃ reducer ┃ ┃ + * ┃ │ ┗━━━━━━━━━━━┛ ┃ + * ┃ │ previous │ ┃ + * ┃ │ state │ new state ┃ + * ┃ │ │ ┃ + * ┃ │ ┏━━━━━▼━━━━━┓ ┃ + * ┃ └────────────┃ state ┃ ┃ + * ┃ ┗━━━━━━━━━━━┛ ┃ + * ┃ │ ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━│━━━━━━━━━━━━━━━━━┛ + * ▼ + * state + * ``` + * + * For implementation details look into: + * 1. [Mutator]: This corresponds to [Action] -> [Mutation] + * 2. [Reducer]: This corresponds to [Mutation] -> [State] + * 3. [Transformer] + * 4. [ControllerImplementation] + * + * To create a [Controller] that is not bound to a [CoroutineScope] look into [ManagedController]. + */ +@ExperimentalCoroutinesApi +@FlowPreview +fun CoroutineScope.createController( + + /** + * The initial [State] for the internal state machine. + */ + initialState: State, + /** + * See [Mutator]. + */ + mutator: Mutator = { _ -> emptyFlow() }, + /** + * See [Reducer]. + */ + reducer: Reducer = { _, previousState -> previousState }, + + /** + * See [Transformer]. + */ + actionsTransformer: Transformer = { it }, + mutationsTransformer: Transformer = { it }, + statesTransformer: Transformer = { 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 = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher +): Controller = ControllerImplementation( + scope = this, dispatcher = dispatcher, controllerStart = controllerStart, + + initialState = initialState, mutator = mutator, reducer = reducer, + actionsTransformer = actionsTransformer, + mutationsTransformer = mutationsTransformer, + statesTransformer = statesTransformer, + + tag = tag, controllerLog = controllerLog +) + +/** + * Creates a [Controller] bound to a [CoroutineScope] where [Action] == [Mutation]. + * This means that the [Controller] can only deal with synchronous state reductions without + * any asynchronous side-effects. + * + * Internally - for the state machine - that means that each [Action] is simply pushed through + * the [Mutator] as it is and thus directly reaches the [Reducer]. + * + * ``` + * Action + * ┏━━━━━━━━━━━━━━━━━━━━━│━━━━━━━━━━━━━━━━━┓ + * ┃ │ ┃ + * ┃ ┏━━━━━▼━━━━━┓ ┃ + * ┃ ┌───────────▶┃ reducer ┃ ┃ + * ┃ │ ┗━━━━━━━━━━━┛ ┃ + * ┃ │ previous │ ┃ + * ┃ │ state │ new state ┃ + * ┃ │ │ ┃ + * ┃ │ ┏━━━━━▼━━━━━┓ ┃ + * ┃ └────────────┃ state ┃ ┃ + * ┃ ┗━━━━━━━━━━━┛ ┃ + * ┃ │ ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━│━━━━━━━━━━━━━━━━━┛ + * ▼ + * state + * ``` + * + * If the [CoroutineScope] is cancelled, the internal state machine of the [Controller] completes. + */ +@ExperimentalCoroutinesApi +@FlowPreview +fun CoroutineScope.createSynchronousController( + + /** + * The initial [State] for the internal state machine. + */ + initialState: State, + /** + * See [Reducer]. + */ + reducer: Reducer = { _, previousState -> previousState }, + + /** + * See [Transformer]. + */ + actionsTransformer: Transformer = { it }, + statesTransformer: Transformer = { 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]. + * + * [Reducer] will run on this [CoroutineDispatcher]. + */ + dispatcher: CoroutineDispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher +): Controller = ControllerImplementation( + scope = this, dispatcher = dispatcher, controllerStart = controllerStart, + + initialState = initialState, mutator = { flowOf(it) }, reducer = reducer, + actionsTransformer = actionsTransformer, + mutationsTransformer = { it }, + statesTransformer = statesTransformer, + + tag = tag, controllerLog = controllerLog +) + /** * A [Mutator] takes an action and transforms it into a [Flow] of [0..n] mutations. * @@ -84,37 +262,20 @@ interface Controller { * } * ``` */ -typealias Mutator = MutatorScope.( - action: Action -) -> Flow +typealias Mutator = MutatorContext.(action: Action) -> Flow /** - * The [MutatorScope] provides access to the [currentState] and the [actions] [Flow] of the - * [ControllerImplementation] in a [Mutator]. + * The [MutatorContext] provides access to the [currentState] and the [actions] [Flow] in a [Mutator]. */ -interface MutatorScope { +interface MutatorContext { /** - * A generated property in [ControllerImplementation.MutatorScopeImpl], thus always - * providing the current [State] when accessed. + * A generated property, thus always providing the current [State] when accessed. */ val currentState: State /** - * Accessed after [Action] [Transformer] is applied. - * - * Use if a [Flow] inside the [Mutator] needs to be cancelled or transformed due to an - * incoming [Action]: - * - * ``` - * mutator = { action -> - * when(action) { - * is Action.Start -> flow { - * emit(someLongRunningSuspendingFunctionThatGeneratesAValue()) - * }.takeUntil(actions.filterIsInstance()) - * } - * } - * ``` + * The [Flow] of incoming actions, accessed after [Action] [Transformer] is applied. */ val actions: Flow } @@ -175,4 +336,4 @@ typealias Reducer = (mutation: Mutation, previousState: State) * } * ``` */ -typealias Transformer = (emissions: Flow) -> Flow \ No newline at end of file +typealias Transformer = (emissions: Flow) -> Flow diff --git a/control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt b/control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt new file mode 100644 index 00000000..527ec02e --- /dev/null +++ b/control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt @@ -0,0 +1,95 @@ +package at.florianschuster.control + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.emptyFlow + +/** + * A [ManagedController] is a [Controller] that additionally provides the ability to [start] + * the internal state machine, but also requires to [cancel] the state machine to prevent leaks. + * + * Before using this, make sure to look into the [Controller] documentation. + */ +interface ManagedController : Controller { + + /** + * Starts the internal state machine of this [ManagedController]. + * + * Returns true if [ManagedController] was started, false if it was already started. + */ + fun start(): Boolean + + /** + * Cancels the internal state machine of this [ManagedController]. + * Once cancelled, the [ManagedController] cannot be re-started. + * + * Returns the last [State] produced by the internal state machine. + */ + fun cancel(): State +} + +/** + * Creates a [ManagedController] via [ControllerImplementation]. + * + * The internal state machine is started when calling [ManagedController.start]. + * A [ManagedController] also HAS to be cancelled via [ManagedController.cancel] to avoid leaks. + */ +@Suppress("FunctionName") +@ExperimentalCoroutinesApi +@FlowPreview +fun ManagedController( + + /** + * The initial [State] for the internal state machine. + */ + initialState: State, + /** + * See [Mutator]. + */ + mutator: Mutator = { _ -> emptyFlow() }, + /** + * See [Reducer]. + */ + reducer: Reducer = { _, previousState -> previousState }, + + /** + * See [Transformer]. + */ + actionsTransformer: Transformer = { it }, + mutationsTransformer: Transformer = { it }, + statesTransformer: Transformer = { 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, + + /** + * The [CoroutineDispatcher] that the internal state machine is launched in. + * + * [Mutator] and [Reducer] will run on this [CoroutineDispatcher]. + */ + dispatcher: CoroutineDispatcher = Dispatchers.Default +): ManagedController = ControllerImplementation( + scope = CoroutineScope(dispatcher), + dispatcher = dispatcher, + controllerStart = ControllerStart.Managed, + + initialState = initialState, + mutator = mutator, + reducer = reducer, + actionsTransformer = actionsTransformer, + mutationsTransformer = mutationsTransformer, + statesTransformer = statesTransformer, + + tag = tag, + controllerLog = controllerLog +) diff --git a/control-core/src/main/kotlin/at/florianschuster/control/builders.kt b/control-core/src/main/kotlin/at/florianschuster/control/builders.kt deleted file mode 100644 index 28b49074..00000000 --- a/control-core/src/main/kotlin/at/florianschuster/control/builders.kt +++ /dev/null @@ -1,116 +0,0 @@ -package at.florianschuster.control - -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.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flowOf -import kotlin.coroutines.ContinuationInterceptor - -/** - * Creates a [Controller] bound to the [CoroutineScope] via [ControllerImplementation]. - */ -@ExperimentalCoroutinesApi -@FlowPreview -fun CoroutineScope.createController( - - /** - * The [ControllerImplementation] is started with this [State]. - */ - initialState: State, - - /** - * See [Mutator]. - */ - mutator: Mutator = { _ -> emptyFlow() }, - - /** - * See [Reducer]. - */ - reducer: Reducer = { _, previousState -> previousState }, - - /** - * See [Transformer]. - */ - actionsTransformer: Transformer = { it }, - mutationsTransformer: Transformer = { it }, - statesTransformer: Transformer = { it }, - - /** - * Set as [CoroutineName] for the [ControllerImplementation.state] context. - * Also used for logging if enabled via [controllerLog]. - */ - tag: String = defaultTag(), - - /** - * Log configuration for the [ControllerImplementation]. See [ControllerLog]. - */ - controllerLog: ControllerLog = ControllerLog.default, - - /** - * When the [ControllerImplementation.state] [Flow] should be started. The [Flow] is launched - * in [ControllerImplementation] init. - * - * Default is [CoroutineStart.LAZY] -> [Flow] is started once [ControllerImplementation.state], - * [ControllerImplementation.currentState] or [ControllerImplementation.dispatch] are accessed - * or if the [Job] in the [CoroutineScope] is started. - * - * Look into [CoroutineStart] to see how the options would affect the [Flow] start. - */ - coroutineStart: CoroutineStart = CoroutineStart.LAZY, - - /** - * Override to launch [ControllerImplementation.state] [Flow] in different [CoroutineDispatcher] - * than the one used in the [CoroutineScope.coroutineContext]. - */ - dispatcher: CoroutineDispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher -): Controller = ControllerImplementation( - scope = this, dispatcher = dispatcher, coroutineStart = coroutineStart, - - initialState = initialState, mutator = mutator, reducer = reducer, - actionsTransformer = actionsTransformer, - mutationsTransformer = mutationsTransformer, - statesTransformer = statesTransformer, - - tag = tag, controllerLog = controllerLog -) - -/** - * Creates a [Controller] with [CoroutineScope.createController] where [Action] == [Mutation]. - * - * The [Controller] can only deal with synchronous state reductions without - * any asynchronous side-effects. - */ -@ExperimentalCoroutinesApi -@FlowPreview -fun CoroutineScope.createSynchronousController( - initialState: State, - reducer: Reducer = { _, previousState -> previousState }, - - actionsTransformer: Transformer = { it }, - statesTransformer: Transformer = { it }, - - tag: String = defaultTag(), - controllerLog: ControllerLog = ControllerLog.default, - - coroutineStart: CoroutineStart = CoroutineStart.LAZY, - dispatcher: CoroutineDispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher -): Controller = createController( - initialState = initialState, - mutator = { action -> flowOf(action) }, - reducer = reducer, - actionsTransformer = actionsTransformer, - mutationsTransformer = { it }, - statesTransformer = statesTransformer, - - tag = tag, - controllerLog = controllerLog, - - coroutineStart = coroutineStart, - dispatcher = dispatcher -) \ No newline at end of file diff --git a/control-core/src/main/kotlin/at/florianschuster/control/defaultTag.kt b/control-core/src/main/kotlin/at/florianschuster/control/defaultTag.kt index 5ac79ee8..3e9f24f0 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/defaultTag.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/defaultTag.kt @@ -1,11 +1,8 @@ package at.florianschuster.control -/** - * Creates a default tag that a [ControllerImplementation] uses for debugging. - */ @Suppress("NOTHING_TO_INLINE") internal inline fun defaultTag(): String { val stackTrace = Throwable().stackTrace check(stackTrace.size >= 2) { "Stacktrace didn't have enough elements." } return stackTrace[1].className.split("$").first().split(".").last() -} \ No newline at end of file +} diff --git a/control-core/src/main/kotlin/at/florianschuster/control/errors.kt b/control-core/src/main/kotlin/at/florianschuster/control/errors.kt index 2d924da3..1ab3d3b7 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/errors.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/errors.kt @@ -29,4 +29,4 @@ internal sealed class ControllerError( message = "Reducer error in $tag, previousState = $previousState, mutation = $mutation", cause = cause ) -} \ No newline at end of file +} 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 d170ccce..319050d8 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/event.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/event.kt @@ -1,7 +1,7 @@ package at.florianschuster.control /** - * All events that are logged in a [ControllerImplementation]. + * All events that are logged in the internal state machine within a [Controller]. */ sealed class ControllerEvent( private val tag: String, @@ -9,62 +9,60 @@ sealed class ControllerEvent( ) { /** - * When the [ControllerImplementation] is created. + * When the implementation is created. */ class Created internal constructor( - tag: String - ) : ControllerEvent(tag, "created") + tag: String, controllerStart: String + ) : ControllerEvent(tag, "created with controllerStart: $controllerStart") /** - * When the [ControllerImplementation.state] stream is started. + * When the state machine is started. */ class Started internal constructor( tag: String ) : ControllerEvent(tag, "state stream started") /** - * When the [ControllerImplementation] receives an [Action]. + * When the state machine receives an [Action]. */ class Action internal constructor( tag: String, action: String ) : ControllerEvent(tag, "action: $action") /** - * When the [ControllerImplementation] mutator produces a new [Mutation]. + * When the [Mutator] produces a new [Mutation]. */ class Mutation internal constructor( tag: String, mutation: String ) : ControllerEvent(tag, "mutation: $mutation") /** - * When the [ControllerImplementation] reduces a new [State]. + * When the [Reducer] reduces a new [State]. */ class State internal constructor( tag: String, state: String ) : ControllerEvent(tag, "state: $state") /** - * When an error happens in [ControllerImplementation] stream. + * When an error happens during the execution of the internal state machine. */ class Error internal constructor( tag: String, cause: ControllerError ) : ControllerEvent(tag, "error: $cause") /** - * When the [ControllerImplementation.stub] is set to enabled. + * When the [ControllerStub] is enabled. */ class Stub internal constructor( tag: String ) : ControllerEvent(tag, "is now stubbed") /** - * When the [ControllerImplementation] stream is completed. + * When the internal state machine completes. */ class Completed internal constructor( tag: String ) : ControllerEvent(tag, "completed") - override fun toString(): String { - return "||| ||| $tag -> $message |||" - } + override fun toString(): String = "||| ||| $tag -> $message |||" } diff --git a/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt b/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt index a08dd577..c9e1d7a3 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt @@ -88,4 +88,4 @@ fun Flow.takeUntil(other: Flow): Flow = flow { } } -private class TakeUntilException : CancellationException() \ No newline at end of file +private class TakeUntilException : CancellationException() 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 8f4072aa..d09f06aa 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt @@ -27,26 +27,66 @@ import kotlinx.coroutines.launch @ExperimentalCoroutinesApi @FlowPreview internal class ControllerImplementation( - internal val scope: CoroutineScope, - internal val dispatcher: CoroutineDispatcher, - internal val coroutineStart: CoroutineStart, + val scope: CoroutineScope, + val dispatcher: CoroutineDispatcher, + val controllerStart: ControllerStart, - internal val initialState: State, - internal val mutator: Mutator, - internal val reducer: Reducer, + val initialState: State, + val mutator: Mutator, + val reducer: Reducer, - internal val actionsTransformer: Transformer, - internal val mutationsTransformer: Transformer, - internal val statesTransformer: Transformer, + val actionsTransformer: Transformer, + val mutationsTransformer: Transformer, + val statesTransformer: Transformer, - internal val tag: String, - internal val controllerLog: ControllerLog -) : Controller { + val tag: String, + val controllerLog: ControllerLog +) : ManagedController { - internal val stateJob: Job + // region state machine private val actionChannel = BroadcastChannel(BUFFERED) - private val stateFlow = MutableStateFlow(initialState) + private val mutableStateFlow = MutableStateFlow(initialState) + + internal val stateJob: Job = scope.launch( + context = dispatcher + CoroutineName(tag), + start = CoroutineStart.LAZY + ) { + val actionFlow: Flow = actionsTransformer(actionChannel.asFlow()) + + val mutatorContext = createMutatorContext({ currentState }, actionFlow) + val mutationFlow: Flow = actionFlow.flatMapMerge { action -> + controllerLog.log(ControllerEvent.Action(tag, action.toString())) + mutatorContext.mutator(action).catch { cause -> + val error = ControllerError.Mutate(tag, "$action", cause) + controllerLog.log(ControllerEvent.Error(tag, error)) + throw error + } + } + + val stateFlow: Flow = mutationsTransformer(mutationFlow) + .onEach { controllerLog.log(ControllerEvent.Mutation(tag, it.toString())) } + .scan(initialState) { previousState, mutation -> + runCatching { reducer(mutation, previousState) }.getOrElse { cause -> + val error = ControllerError.Reduce( + tag, "$previousState", "$mutation", cause + ) + controllerLog.log(ControllerEvent.Error(tag, error)) + throw error + } + } + + statesTransformer(stateFlow) + .onStart { controllerLog.log(ControllerEvent.Started(tag)) } + .onEach { state -> + controllerLog.log(ControllerEvent.State(tag, state.toString())) + mutableStateFlow.value = state + } + .onCompletion { controllerLog.log(ControllerEvent.Completed(tag)) } + .collect() + } + + // endregion // region stub @@ -59,79 +99,55 @@ internal class ControllerImplementation( override val state: Flow get() = if (stubInitialized) stub.stateFlow else { - if (!stateJob.isActive) startStateJob() - stateFlow + if (controllerStart is ControllerStart.Lazy) start() + mutableStateFlow } override val currentState: State get() = if (stubInitialized) stub.stateFlow.value else { - if (!stateJob.isActive) startStateJob() - stateFlow.value + if (controllerStart is ControllerStart.Lazy) start() + mutableStateFlow.value } override fun dispatch(action: Action) { if (stubInitialized) { stub.mutableDispatchedActions.add(action) } else { - if (!stateJob.isActive) startStateJob() + if (controllerStart is ControllerStart.Lazy) start() actionChannel.offer(action) } } - init { - stateJob = scope.launch( - context = dispatcher + CoroutineName(tag), - start = coroutineStart - ) { - val actionFlow: Flow = actionsTransformer(actionChannel.asFlow()) - - val mutatorScope = mutatorScope({ currentState }, actionFlow) - val mutationFlow: Flow = actionFlow.flatMapMerge { action -> - controllerLog.log(ControllerEvent.Action(tag, action.toString())) - mutatorScope.mutator(action).catch { cause -> - val error = ControllerError.Mutate(tag, "$action", cause) - controllerLog.log(ControllerEvent.Error(tag, error)) - throw error - } - } - - val stateFlow: Flow = mutationsTransformer(mutationFlow) - .onEach { controllerLog.log(ControllerEvent.Mutation(tag, it.toString())) } - .scan(initialState) { previousState, mutation -> - runCatching { reducer(mutation, previousState) }.getOrElse { cause -> - val error = ControllerError.Reduce( - tag, "$previousState", "$mutation", cause - ) - controllerLog.log(ControllerEvent.Error(tag, error)) - throw error - } - } + // endregion - statesTransformer(stateFlow) - .onStart { controllerLog.log(ControllerEvent.Started(tag)) } - .onEach { state -> - controllerLog.log(ControllerEvent.State(tag, state.toString())) - this@ControllerImplementation.stateFlow.value = state - } - .onCompletion { controllerLog.log(ControllerEvent.Completed(tag)) } - .collect() - } + // region managed controller - controllerLog.log(ControllerEvent.Created(tag)) + override fun start(): Boolean { + return if (stateJob.isActive) false else stateJob.start() } - internal fun startStateJob(): Boolean = kotlinx.atomicfu.locks.synchronized(this) { - return if (stateJob.isActive) false // double checked locking - else stateJob.start() + override fun cancel(): State { + stateJob.cancel() + return mutableStateFlow.value } // endregion -} -internal fun mutatorScope( - stateAccessor: () -> State, - actionFlow: Flow -): MutatorScope = object : MutatorScope { - override val currentState: State get() = stateAccessor() - override val actions: Flow = actionFlow -} \ No newline at end of file + init { + controllerLog.log(ControllerEvent.Created(tag, controllerStart.logName)) + if (controllerStart is ControllerStart.Immediately) { + start() + } + } + + companion object { + fun createMutatorContext( + stateAccessor: () -> State, + actionFlow: Flow + ): MutatorContext = object : + MutatorContext { + override val currentState: State get() = stateAccessor() + override val actions: Flow = actionFlow + } + } +} diff --git a/control-core/src/main/kotlin/at/florianschuster/control/log.kt b/control-core/src/main/kotlin/at/florianschuster/control/log.kt index 6fc8cdfd..7a0ef85c 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/log.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/log.kt @@ -3,17 +3,17 @@ package at.florianschuster.control /** * A logger used by [ControllerLog] to log [ControllerEvent]'s. */ -typealias Logger = LoggerScope.(message: String) -> Unit +typealias Logger = LoggerContext.(message: String) -> Unit /** - * The scope of a [Logger]. Contains the [ControllerEvent] that is being logged. + * The context of a [Logger]. Contains the [ControllerEvent] that is being logged. */ -interface LoggerScope { +interface LoggerContext { val event: ControllerEvent } /** - * Configuration to define how [ControllerEvent]'s are logged by a [ControllerImplementation]. + * Configuration to define how [ControllerEvent]'s are logged by a [Controller]. */ sealed class ControllerLog { @@ -39,17 +39,17 @@ sealed class ControllerLog { companion object { /** - * The default [ControllerLog] that is used by a [ControllerImplementation]. - * Set this to change the logger for all [ControllerImplementation]'s that do not specify one. + * The default [ControllerLog] that is used by all [Controller] builders. + * Set this to change the default logger for all builders that do not specify one. */ var default: ControllerLog = None } } -internal fun ControllerLog.log(event: ControllerEvent) { - logger?.invoke(loggerScope(event), event.toString()) +internal fun createLoggerContext(event: ControllerEvent) = object : LoggerContext { + override val event: ControllerEvent = event } -internal fun loggerScope(event: ControllerEvent) = object : LoggerScope { - override val event: ControllerEvent = event -} \ No newline at end of file +internal fun ControllerLog.log(event: ControllerEvent) { + logger?.invoke(createLoggerContext(event), event.toString()) +} diff --git a/control-core/src/main/kotlin/at/florianschuster/control/start.kt b/control-core/src/main/kotlin/at/florianschuster/control/start.kt new file mode 100644 index 00000000..46e6ccef --- /dev/null +++ b/control-core/src/main/kotlin/at/florianschuster/control/start.kt @@ -0,0 +1,34 @@ +package at.florianschuster.control + +import kotlinx.coroutines.CoroutineScope + +/** + * Options for [Controller] builder such as [CoroutineScope.createController] or + * [ManagedController] to define when the internal state machine should be started. + */ +sealed class ControllerStart { + + internal abstract val logName: String + + /** + * The state machine is started once [Controller.state], [Controller.currentState] or + * [Controller.dispatch] are accessed. + */ + object Lazy : ControllerStart() { + override val logName: String = "Lazy" + } + + /** + * The state machine is iImmediately started once the [Controller] is built. + */ + object Immediately : ControllerStart() { + override val logName: String = "Immediately" + } + + /** + * The state machine is started once [ManagedController.start] is called. + */ + internal object Managed : ControllerStart() { + override val logName: String = "Managed" + } +} 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 a1ba5074..55afea0f 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt @@ -59,4 +59,4 @@ internal class ControllerStubImplementation( override fun emitState(state: State) { stateFlow.value = state } -} \ No newline at end of file +} diff --git a/control-core/src/test/kotlin/at/florianschuster/control/BuildersTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/BuildersTest.kt deleted file mode 100644 index 63b89772..00000000 --- a/control-core/src/test/kotlin/at/florianschuster/control/BuildersTest.kt +++ /dev/null @@ -1,72 +0,0 @@ -package at.florianschuster.control - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineStart -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.coroutines.ContinuationInterceptor -import kotlin.test.assertEquals - -internal class BuildersTest { - - @Test - fun `default parameters of controller builder`() = runBlockingTest { - val expectedInitialState = 42 - val expectedTag = defaultTag() - val sut = createController( - initialState = expectedInitialState, - tag = expectedTag - ) as ControllerImplementation - - assertEquals(this, sut.scope) - assertEquals(expectedInitialState, sut.initialState) - - assertEquals(null, sut.mutator.invoke(mutatorScope({ 1 }, flowOf(2)), 3).singleOrNull()) - assertEquals(1, sut.reducer.invoke(0, 1)) - - assertEquals(1, sut.actionsTransformer.invoke(flowOf(1)).single()) - assertEquals(2, sut.mutationsTransformer.invoke(flowOf(2)).single()) - assertEquals(3, sut.statesTransformer.invoke(flowOf(3)).single()) - - assertEquals(expectedTag, sut.tag) - assertEquals(ControllerLog.default, sut.controllerLog) - - assertEquals(CoroutineStart.LAZY, sut.coroutineStart) - assertEquals( - coroutineContext[ContinuationInterceptor] as CoroutineDispatcher, - sut.dispatcher - ) - } - - @Test - fun `default parameters of synchronous controller builder`() = runBlockingTest { - val expectedInitialState = 42 - val expectedTag = defaultTag() - val sut = createSynchronousController( - initialState = expectedInitialState, - tag = expectedTag - ) as ControllerImplementation - - assertEquals(this, sut.scope) - assertEquals(expectedInitialState, sut.initialState) - - assertEquals(3, sut.mutator.invoke(mutatorScope({ 1 }, flowOf(2)), 3).single()) - assertEquals(1, sut.reducer.invoke(0, 1)) - - assertEquals(1, sut.actionsTransformer.invoke(flowOf(1)).single()) - assertEquals(2, sut.mutationsTransformer.invoke(flowOf(2)).single()) - assertEquals(3, sut.statesTransformer.invoke(flowOf(3)).single()) - - assertEquals(expectedTag, sut.tag) - assertEquals(ControllerLog.default, sut.controllerLog) - - assertEquals(CoroutineStart.LAZY, sut.coroutineStart) - assertEquals( - coroutineContext[ContinuationInterceptor] as CoroutineDispatcher, - sut.dispatcher - ) - } -} \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ControllerStartTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ControllerStartTest.kt deleted file mode 100644 index 0a163f82..00000000 --- a/control-core/src/test/kotlin/at/florianschuster/control/ControllerStartTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package at.florianschuster.control - -import at.florianschuster.test.coroutines.TestCoroutineScopeRule -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import org.junit.Rule -import org.junit.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -internal class ControllerStartTest { - - @get:Rule - val testCoroutineScope = TestCoroutineScopeRule() - - @Test - fun `default start mode`() { - val sut = testCoroutineScope.counterController(coroutineStart = CoroutineStart.DEFAULT) - - assertTrue(sut.stateJob.isActive) - } - - @Test - fun `lazy start mode`() { - val sut = testCoroutineScope.counterController(coroutineStart = CoroutineStart.LAZY) - - assertFalse(sut.stateJob.isActive) - sut.currentState - assertTrue(sut.stateJob.isActive) - } - - @Test - fun `manually start job`() { - val sut = testCoroutineScope.counterController(coroutineStart = CoroutineStart.LAZY) - - assertFalse(sut.stateJob.isActive) - sut.stateJob.start() - assertTrue(sut.stateJob.isActive) - } - - @Test - fun `manually start via implementation_startStateJob`() { - val sut = testCoroutineScope.counterController(coroutineStart = CoroutineStart.LAZY) - - assertFalse(sut.stateJob.isActive) - sut.startStateJob() - assertTrue(sut.stateJob.isActive) - } - - @Test - fun `verify double checked locking`() { - val sut = testCoroutineScope.counterController(coroutineStart = CoroutineStart.LAZY) - - assertFalse(sut.stateJob.isActive) - sut.stateJob.start() - assertFalse(sut.startStateJob()) - } - - private fun CoroutineScope.counterController( - coroutineStart: CoroutineStart - ) = createSynchronousController( - initialState = 0, - coroutineStart = coroutineStart - ) as ControllerImplementation -} \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt new file mode 100644 index 00000000..455343f8 --- /dev/null +++ b/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt @@ -0,0 +1,60 @@ +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 ControllerTest { + + @Test + fun `default parameters of controller builder`() = runBlockingTest { + val expectedInitialState = 42 + val sut = createController( + initialState = expectedInitialState + ) as ControllerImplementation + + assertEquals(this, sut.scope) + assertEquals(expectedInitialState, sut.initialState) + + assertEquals(null, sut.mutator(mockk(), 3).singleOrNull()) + assertEquals(1, sut.reducer(0, 1)) + + assertEquals(1, sut.actionsTransformer(flowOf(1)).single()) + assertEquals(2, sut.mutationsTransformer(flowOf(2)).single()) + assertEquals(3, sut.statesTransformer(flowOf(3)).single()) + + assertEquals(defaultTag(), sut.tag) + assertEquals(ControllerLog.default, sut.controllerLog) + + assertEquals(ControllerStart.Lazy, sut.controllerStart) + assertEquals(scopeDispatcher, sut.dispatcher) + } + + @Test + fun `default parameters of synchronous controller builder`() = runBlockingTest { + val expectedInitialState = 42 + val sut = createSynchronousController( + initialState = expectedInitialState + ) as ControllerImplementation + + assertEquals(this, sut.scope) + assertEquals(expectedInitialState, sut.initialState) + + assertEquals(3, sut.mutator(mockk(), 3).single()) + assertEquals(1, sut.reducer(0, 1)) + + assertEquals(1, sut.actionsTransformer(flowOf(1)).single()) + assertEquals(2, sut.mutationsTransformer(flowOf(2)).single()) + assertEquals(3, sut.statesTransformer(flowOf(3)).single()) + + assertEquals(defaultTag(), sut.tag) + assertEquals(ControllerLog.default, sut.controllerLog) + + assertEquals(ControllerStart.Lazy, sut.controllerStart) + assertEquals(scopeDispatcher, sut.dispatcher) + } +} diff --git a/control-core/src/test/kotlin/at/florianschuster/control/DefaultTagTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/DefaultTagTest.kt index c86b3aaf..e04d50f5 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/DefaultTagTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/DefaultTagTest.kt @@ -16,7 +16,7 @@ internal class DefaultTagTest { } @Test - fun `defaultTag in anonymous class`() { + fun `defaultTag in anonymous object`() { val sut = object { val tag = defaultTag() } 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 6a1186f4..ad0165d5 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt @@ -11,7 +11,7 @@ internal class EventTest { @Test fun `event message contains library name and tag`() { val tag = "some_tag" - val event = ControllerEvent.Created(tag) + val event = ControllerEvent.Completed(tag) assertTrue(event.toString().contains("control")) assertTrue(event.toString().contains(tag)) @@ -20,11 +20,15 @@ internal class EventTest { @Test fun `ControllerImplementation logs events correctly`() { val events = mutableListOf() - val sut = TestCoroutineScope().eventsController(events) + val sut = TestCoroutineScope().eventsController( + events, + controllerStart = ControllerStart.Managed + ) assertTrue(events.last() is ControllerEvent.Created) + assertTrue(events.last().toString().contains(ControllerStart.Managed.logName)) - sut.startStateJob() + sut.start() events.takeLast(2).let { lastEvents -> assertTrue(lastEvents[0] is ControllerEvent.Started) assertTrue(lastEvents[1] is ControllerEvent.State) @@ -40,7 +44,7 @@ internal class EventTest { sut.stub() assertTrue(events.last() is ControllerEvent.Stub) - sut.stateJob.cancel() + sut.cancel() assertTrue(events.last() is ControllerEvent.Completed) } @@ -69,8 +73,12 @@ internal class EventTest { } private fun CoroutineScope.eventsController( - events: MutableList - ) = createController( + events: MutableList, + controllerStart: ControllerStart = ControllerStart.Lazy + ) = ControllerImplementation( + scope = this, + dispatcher = scopeDispatcher, + controllerStart = controllerStart, initialState = 0, mutator = { action -> flow { @@ -82,8 +90,12 @@ internal class EventTest { check(mutation != reducerErrorValue) previousState }, + actionsTransformer = { it }, + mutationsTransformer = { it }, + statesTransformer = { it }, + tag = "ImplementationEventTest.EventsController", controllerLog = ControllerLog.Custom { events.add(event) } - ) as ControllerImplementation + ) companion object { private const val mutatorErrorValue = 42 diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt index f381fb3f..bb88110a 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt @@ -28,8 +28,7 @@ internal class ExtensionsTest { @Test(expected = IllegalStateException::class) fun `bind lambda throws error`() = runBlockingTest { - val lambda = spyk<(Int) -> Unit>() - flow { error("test") }.bind(to = lambda).launchIn(this) + flow { error("test") }.bind { }.launchIn(this) } @Test 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 f6f03666..2e14ec90 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt @@ -5,9 +5,11 @@ 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.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow @@ -27,7 +29,7 @@ internal class ImplementationTest { @Test fun `initial state only emitted once`() { - val sut = testCoroutineScope.operationController() + val sut = testCoroutineScope.createOperationController() val testFlow = sut.state.testIn(testCoroutineScope) testFlow expect emissionCount(1) @@ -36,14 +38,14 @@ internal class ImplementationTest { @Test fun `state is created when accessing current state`() { - val sut = testCoroutineScope.operationController() + val sut = testCoroutineScope.createOperationController() assertEquals(listOf("initialState", "transformedState"), sut.currentState) } @Test fun `state is created when accessing action`() { - val sut = testCoroutineScope.operationController() + val sut = testCoroutineScope.createOperationController() sut.dispatch(listOf("action")) @@ -62,7 +64,7 @@ internal class ImplementationTest { @Test fun `each method is invoked`() { - val sut = testCoroutineScope.operationController() + val sut = testCoroutineScope.createOperationController() val testFlow = sut.state.testIn(testCoroutineScope) sut.dispatch(listOf("action")) @@ -81,27 +83,9 @@ internal class ImplementationTest { ) } - @Test - fun `synchronous controller`() { - val counterSut = testCoroutineScope.createSynchronousController( - tag = "counter", - initialState = 0, - reducer = { action, previousState -> previousState + action } - ) - - counterSut.dispatch(1) - counterSut.dispatch(2) - counterSut.dispatch(3) - - assertEquals(6, counterSut.currentState) - } - @Test fun `only distinct states are emitted`() { - val sut = testCoroutineScope.createSynchronousController( - 0, - reducer = { _, previousState -> previousState } - ) + val sut = testCoroutineScope.createAlwaysSameStateController() val testFlow = sut.state.testIn(testCoroutineScope) sut.dispatch(Unit) sut.dispatch(Unit) @@ -111,7 +95,7 @@ internal class ImplementationTest { @Test fun `collector receives latest and following states`() { - val sut = testCoroutineScope.counterController() // 0 + val sut = testCoroutineScope.createCounterController() // 0 sut.dispatch(Unit) // 1 sut.dispatch(Unit) // 2 @@ -126,7 +110,7 @@ internal class ImplementationTest { @Test fun `state flow throws error from mutator`() { val scope = TestCoroutineScope() - val sut = scope.counterController(mutatorErrorIndex = 2) + val sut = scope.createCounterController(mutatorErrorIndex = 2) sut.dispatch(Unit) sut.dispatch(Unit) sut.dispatch(Unit) @@ -137,7 +121,7 @@ internal class ImplementationTest { @Test fun `state flow throws error from reducer`() { val scope = TestCoroutineScope() - val sut = scope.counterController(reducerErrorIndex = 2) + val sut = scope.createCounterController(reducerErrorIndex = 2) sut.dispatch(Unit) sut.dispatch(Unit) @@ -148,7 +132,7 @@ internal class ImplementationTest { @Test fun `cancel via takeUntil`() { - val sut = testCoroutineScope.stopWatchController() + val sut = testCoroutineScope.createStopWatchController() sut.dispatch(StopWatchAction.Start) testCoroutineScope.advanceTimeBy(2000) @@ -185,11 +169,7 @@ internal class ImplementationTest { emit(42) } - val sut = testCoroutineScope.createSynchronousController( - initialState = 0, - actionsTransformer = { merge(it, globalState) }, - reducer = { action, previousState -> previousState + action } - ) + val sut = testCoroutineScope.createGlobalStateMergeController(globalState) val states = sut.state.testIn(testCoroutineScope) @@ -201,17 +181,51 @@ internal class ImplementationTest { } @Test - fun `MutatorScope is built correctly`() { + fun `MutatorContext is built correctly`() { val stateAccessor = { 1 } val actions = flowOf(1) - val sut = mutatorScope(stateAccessor, actions) + val sut = ControllerImplementation.createMutatorContext(stateAccessor, actions) assertEquals(stateAccessor(), sut.currentState) assertEquals(actions, sut.actions) } - private fun CoroutineScope.operationController() = - createController, List, List>( + @Test + fun `cancelling the implementation will return the last state`() { + val sut = testCoroutineScope.createGlobalStateMergeController(emptyFlow()) + + val states = sut.state.testIn(testCoroutineScope) + + sut.dispatch(0) + sut.dispatch(1) + + assertEquals(1, sut.cancel()) + + sut.dispatch(2) + + states expect lastEmission(1) + } + + private fun CoroutineScope.createAlwaysSameStateController() = + ControllerImplementation( + scope = this, + dispatcher = scopeDispatcher, + controllerStart = ControllerStart.Lazy, + initialState = 0, + mutator = { flowOf(it) }, + reducer = { _, previousState -> previousState }, + actionsTransformer = { it }, + mutationsTransformer = { it }, + statesTransformer = { it }, + tag = "ImplementationTest.AlwaysSameStateController", + controllerLog = ControllerLog.None + ) + + private fun CoroutineScope.createOperationController() = + ControllerImplementation, List, List>( + scope = this, + dispatcher = scopeDispatcher, + controllerStart = ControllerStart.Lazy, // 1. ["initialState"] initialState = listOf("initialState"), @@ -235,13 +249,19 @@ internal class ImplementationTest { reducer = { mutation, previousState -> previousState + mutation }, // 6. ["initialState", "action", "transformedAction", "mutation", "transformedMutation"] + ["transformedState"] - statesTransformer = { states -> states.map { it + "transformedState" } } + statesTransformer = { states -> states.map { it + "transformedState" } }, + + tag = "ImplementationTest.OperationController", + controllerLog = ControllerLog.None ) - private fun CoroutineScope.counterController( + private fun CoroutineScope.createCounterController( mutatorErrorIndex: Int? = null, reducerErrorIndex: Int? = null - ) = createController( + ) = ControllerImplementation( + scope = this, + dispatcher = scopeDispatcher, + controllerStart = ControllerStart.Lazy, initialState = 0, mutator = { action -> flow { @@ -252,7 +272,12 @@ internal class ImplementationTest { reducer = { _, previousState -> check(previousState != reducerErrorIndex) previousState + 1 - } + }, + actionsTransformer = { it }, + mutationsTransformer = { it }, + statesTransformer = { it }, + tag = "ImplementationTest.CounterController", + controllerLog = ControllerLog.None ) private sealed class StopWatchAction { @@ -260,21 +285,46 @@ internal class ImplementationTest { object Stop : StopWatchAction() } - private fun CoroutineScope.stopWatchController() = createController( - initialState = 0, - mutator = { action -> - when (action) { - is StopWatchAction.Start -> { - flow { - while (true) { - delay(1000) - emit(1) - } - }.takeUntil(actions.filterIsInstance()) + private fun CoroutineScope.createStopWatchController() = + ControllerImplementation( + scope = this, + dispatcher = scopeDispatcher, + controllerStart = ControllerStart.Lazy, + initialState = 0, + mutator = { action -> + when (action) { + is StopWatchAction.Start -> { + flow { + while (true) { + delay(1000) + emit(1) + } + }.takeUntil(actions.filterIsInstance()) + } + is StopWatchAction.Stop -> emptyFlow() } - is StopWatchAction.Stop -> emptyFlow() - } - }, - reducer = { mutation, previousState -> previousState + mutation } + }, + reducer = { mutation, previousState -> previousState + mutation }, + actionsTransformer = { it }, + mutationsTransformer = { it }, + statesTransformer = { it }, + tag = "ImplementationTest.StopWatchController", + controllerLog = ControllerLog.None + ) + + private fun CoroutineScope.createGlobalStateMergeController( + globalState: Flow + ) = ControllerImplementation( + scope = this, + dispatcher = scopeDispatcher, + controllerStart = ControllerStart.Lazy, + initialState = 0, + mutator = { flowOf(it) }, + reducer = { action, previousState -> previousState + action }, + actionsTransformer = { merge(it, globalState) }, + mutationsTransformer = { it }, + statesTransformer = { it }, + tag = "ImplementationTest.GlobalStateMergeController", + controllerLog = ControllerLog.None ) } \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ControllerLogTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/LogTest.kt similarity index 78% rename from control-core/src/test/kotlin/at/florianschuster/control/ControllerLogTest.kt rename to control-core/src/test/kotlin/at/florianschuster/control/LogTest.kt index 83184af3..e0d6ecfc 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ControllerLogTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/LogTest.kt @@ -13,7 +13,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull -internal class ControllerLogTest { +internal class LogTest { @Test fun `setting default logger`() { @@ -42,8 +42,8 @@ internal class ControllerLogTest { sut.log(CreatedEvent) assertEquals(CreatedEvent.toString(), capturedLogMessage.captured) - sut.log(DestroyedEvent) - assertEquals(DestroyedEvent.toString(), capturedLogMessage.captured) + sut.log(CompletedEvent) + assertEquals(CompletedEvent.toString(), capturedLogMessage.captured) verify(exactly = 2) { out.println(any()) } } @@ -57,21 +57,19 @@ internal class ControllerLogTest { sut.log(CreatedEvent) assertEquals(CreatedEvent.toString(), capturedLogMessage.captured) - sut.log(DestroyedEvent) - assertEquals(DestroyedEvent.toString(), capturedLogMessage.captured) + sut.log(CompletedEvent) + assertEquals(CompletedEvent.toString(), capturedLogMessage.captured) } @Test - fun `LoggerScope factory function`() { - val event = ControllerEvent.Created(tag) - val scope = loggerScope(event) - - assertEquals(event, scope.event) + fun `LoggerContext factory function`() { + val sut = createLoggerContext(CreatedEvent) + assertEquals(CreatedEvent, sut.event) } companion object { private const val tag = "TestTag" - private val CreatedEvent: ControllerEvent = ControllerEvent.Created(tag) - private val DestroyedEvent: ControllerEvent = ControllerEvent.Created(tag) + private val CreatedEvent: ControllerEvent = ControllerEvent.Created(tag, "lazy") + private val CompletedEvent: ControllerEvent = ControllerEvent.Completed(tag) } } \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt new file mode 100644 index 00000000..dbdb75c2 --- /dev/null +++ b/control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt @@ -0,0 +1,39 @@ +package at.florianschuster.control + +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +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.coroutines.ContinuationInterceptor +import kotlin.test.assertEquals + +internal class ManagedControllerTest { + + @Test + fun `default parameters of managed controller builder`() = runBlockingTest { + val expectedInitialState = 42 + val sut = ManagedController( + expectedInitialState, + dispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher + ) as ControllerImplementation + + assertEquals(scopeDispatcher, sut.scope.scopeDispatcher) + assertEquals(expectedInitialState, sut.initialState) + + assertEquals(null, sut.mutator(mockk(), 3).singleOrNull()) + assertEquals(1, sut.reducer(0, 1)) + + assertEquals(1, sut.actionsTransformer(flowOf(1)).single()) + assertEquals(2, sut.mutationsTransformer(flowOf(2)).single()) + assertEquals(3, sut.statesTransformer(flowOf(3)).single()) + + assertEquals(defaultTag(), sut.tag) + assertEquals(ControllerLog.default, sut.controllerLog) + + assertEquals(ControllerStart.Managed, sut.controllerStart) + assertEquals(scopeDispatcher, sut.dispatcher) + } +} \ 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 new file mode 100644 index 00000000..7981b875 --- /dev/null +++ b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt @@ -0,0 +1,148 @@ +package at.florianschuster.control + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestCoroutineScope +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class StartTest { + + @Test + fun `start mode logName`() { + assertEquals("Lazy", ControllerStart.Lazy.logName) + assertEquals("Immediately", ControllerStart.Immediately.logName) + assertEquals("Managed", ControllerStart.Managed.logName) + } + + @Test + fun `default start mode`() { + val scope = TestCoroutineScope(Job()) + val sut = scope.createSimpleCounterController(controllerStart = ControllerStart.Immediately) + assertTrue(sut.stateJob.isActive) + + scope.cancel() + assertFalse(sut.stateJob.isActive) + } + + @Test + fun `lazy start mode`() { + 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 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 sut = scope.createSimpleCounterController(controllerStart = ControllerStart.Lazy) + assertFalse(sut.stateJob.isActive) + + sut.state + assertTrue(sut.stateJob.isActive) + + scope.cancel() + assertFalse(sut.stateJob.isActive) + } + + @Test + fun `lazy start mode with dispatch`() { + val scope = TestCoroutineScope(Job()) + val sut = scope.createSimpleCounterController(controllerStart = ControllerStart.Lazy) + assertFalse(sut.stateJob.isActive) + + sut.dispatch(1) + assertTrue(sut.stateJob.isActive) + + scope.cancel() + assertFalse(sut.stateJob.isActive) + } + + @Test + fun `managed start mode`() { + val scope = TestCoroutineScope(Job()) + val sut = scope.createSimpleCounterController(controllerStart = ControllerStart.Managed) + assertFalse(sut.stateJob.isActive) + + sut.currentState + sut.state + sut.dispatch(1) + assertFalse(sut.stateJob.isActive) + + val started = sut.start() + assertTrue(started) + assertTrue(sut.stateJob.isActive) + + sut.cancel() + assertFalse(sut.stateJob.isActive) + } + + @Test + fun `managed start mode, start when already started`() { + val sut = TestCoroutineScope().createSimpleCounterController( + controllerStart = ControllerStart.Managed + ) + assertFalse(sut.stateJob.isActive) + + sut.start() + val started = sut.start() + assertFalse(started) + assertTrue(sut.stateJob.isActive) + } + + @Test + fun `managed start mode, cancel implementation`() { + val sut = TestCoroutineScope().createSimpleCounterController( + controllerStart = ControllerStart.Managed + ) + assertFalse(sut.stateJob.isActive) + + val started = sut.start() + assertTrue(sut.stateJob.isActive) + assertTrue(started) + + sut.dispatch(42) + val lastState = sut.cancel() + assertFalse(sut.stateJob.isActive) + assertEquals(42, lastState) + } + + private fun CoroutineScope.createSimpleCounterController( + controllerStart: ControllerStart + ) = ControllerImplementation( + scope = this, + dispatcher = scopeDispatcher, + controllerStart = controllerStart, + initialState = 0, + mutator = { flowOf(it) }, + reducer = { mutation, previousState -> previousState + mutation }, + actionsTransformer = { it }, + mutationsTransformer = { it }, + statesTransformer = { it }, + tag = "ImplementationStartStopTest.SimpleCounterController", + controllerLog = ControllerLog.None + ) +} \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt index 51c18377..fc8abf2d 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt @@ -6,6 +6,7 @@ import at.florianschuster.test.flow.expect import at.florianschuster.test.flow.testIn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import org.junit.Rule import org.junit.Test import java.lang.IllegalArgumentException @@ -33,7 +34,6 @@ internal class StubTest { @Test fun `stub is initialized only after accessing stub()`() { val sut = testCoroutineScope.createStringController() - as ControllerImplementation, List, List> assertFalse(sut.stubInitialized) assertFailsWith { sut.stub.dispatchedActions } @@ -101,10 +101,18 @@ internal class StubTest { } private fun CoroutineScope.createStringController() = - createSynchronousController, List>( - tag = "string_controller", + ControllerImplementation, List, List>( + scope = this, + dispatcher = scopeDispatcher, + controllerStart = ControllerStart.Lazy, initialState = initialState, - reducer = { previousState, mutation -> previousState + mutation } + mutator = { flowOf(it) }, + reducer = { previousState, mutation -> previousState + mutation }, + actionsTransformer = { it }, + mutationsTransformer = { it }, + statesTransformer = { it }, + tag = "StubTest.StringController", + controllerLog = ControllerLog.None ) companion object { diff --git a/control-core/src/test/kotlin/at/florianschuster/control/TestHelper.kt b/control-core/src/test/kotlin/at/florianschuster/control/TestHelper.kt new file mode 100644 index 00000000..b91c981b --- /dev/null +++ b/control-core/src/test/kotlin/at/florianschuster/control/TestHelper.kt @@ -0,0 +1,8 @@ +package at.florianschuster.control + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlin.coroutines.ContinuationInterceptor + +internal val CoroutineScope.scopeDispatcher: CoroutineDispatcher + get() = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher diff --git a/examples/android-counter-compose/build.gradle.kts b/examples/android-counter-compose/build.gradle.kts index 25589ff4..87d3a634 100644 --- a/examples/android-counter-compose/build.gradle.kts +++ b/examples/android-counter-compose/build.gradle.kts @@ -29,11 +29,9 @@ android { sourceSets["test"].java.srcDir("src/test/kotlin") sourceSets["androidTest"].java.srcDir("src/androidTest/kotlin") sourceSets["debug"].java.srcDir("src/debug/kotlin") - buildFeatures { compose = true } - composeOptions { kotlinCompilerVersion = "1.3.70-dev-withExperimentalGoogleExtensions-20200424" kotlinCompilerExtensionVersion = Versions.androidx_ui @@ -42,7 +40,6 @@ android { dependencies { implementation(project(":control-core")) - implementation(project(":examples:kotlin-counter")) implementation(Libs.appcompat) implementation(Libs.lifecycle_runtime_ktx) diff --git a/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt b/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt index 6202b20e..168c3c60 100644 --- a/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt +++ b/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt @@ -1,17 +1,12 @@ package at.florianschuster.control.androidcountercomposeexample -import androidx.compose.getValue import androidx.ui.test.assertIsDisplayed import androidx.ui.test.createComposeRule import androidx.ui.test.doClick import androidx.ui.test.findByTag import androidx.ui.test.findByText -import at.florianschuster.control.kotlincounter.CounterAction -import at.florianschuster.control.kotlincounter.CounterController -import at.florianschuster.control.kotlincounter.CounterState -import at.florianschuster.control.kotlincounter.createCounterController +import at.florianschuster.control.ControllerStub import at.florianschuster.control.stub -import kotlinx.coroutines.test.TestCoroutineScope import org.junit.Before import org.junit.Rule import org.junit.Test @@ -25,15 +20,13 @@ internal class CounterScreenTest { @get:Rule val composeTestRule = createComposeRule() - private lateinit var controller: CounterController + private lateinit var stub: ControllerStub @Before fun setup() { - controller = TestCoroutineScope().createCounterController().apply { stub() } - composeTestRule.setContent { - val state by controller.collectState() - CounterScreen(counterState = state, action = controller::dispatch) - } + val controller = CounterController() + stub = controller.stub() + composeTestRule.setContent { CounterScreen(controller) } } @Test @@ -45,7 +38,7 @@ internal class CounterScreenTest { } // then - assertEquals(CounterAction.Increment, controller.stub().dispatchedActions.last()) + assertEquals(CounterAction.Increment, stub.dispatchedActions.last()) } @Test @@ -57,7 +50,7 @@ internal class CounterScreenTest { } // then - assertEquals(CounterAction.Decrement, controller.stub().dispatchedActions.last()) + assertEquals(CounterAction.Decrement, stub.dispatchedActions.last()) } @Test @@ -66,7 +59,7 @@ internal class CounterScreenTest { val testValue = 42 // when - controller.stub().emitState(CounterState(value = testValue)) + stub.emitState(CounterState(value = testValue)) // then findByText("$testValue").assertIsDisplayed() @@ -75,7 +68,7 @@ internal class CounterScreenTest { @Test fun whenStateOffersNotLoadingProgressBarDoesNotExist() { // when - controller.stub().emitState(CounterState(loading = false)) + stub.emitState(CounterState(loading = false)) // then findByTag("progressIndicator").assertDoesNotExist() diff --git a/examples/android-counter-compose/src/main/AndroidManifest.xml b/examples/android-counter-compose/src/main/AndroidManifest.xml index de3a4b63..73de3f7f 100644 --- a/examples/android-counter-compose/src/main/AndroidManifest.xml +++ b/examples/android-counter-compose/src/main/AndroidManifest.xml @@ -12,9 +12,12 @@ android:theme="@style/Theme.AppCompat.DayNight.NoActionBar" tools:ignore="AllowBackup,GoogleAppIndexingWarning"> - + + diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt index 35958be7..cd991abc 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt @@ -3,7 +3,6 @@ package at.florianschuster.control.androidcountercomposeexample import android.os.Bundle import androidx.activity.ComponentActivity import androidx.compose.Composable -import androidx.compose.getValue import androidx.ui.core.ContextAmbient import androidx.ui.core.setContent import androidx.ui.foundation.Text @@ -15,28 +14,23 @@ import androidx.ui.material.Scaffold import androidx.ui.material.TopAppBar import androidx.ui.material.darkColorPalette import androidx.ui.material.lightColorPalette -import at.florianschuster.control.kotlincounter.createCounterController internal class AppActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { AppScreen() } + setContent { App() } } } @Composable -private fun AppScreen() { - val controller = ComposeCoroutineScope().createCounterController() +private fun App() { MaterialTheme(colors = AppColors.currentColorPalette) { Scaffold( topAppBar = { TopAppBar(title = { Text(text = ContextAmbient.current.getString(R.string.app_name)) }) }, - bodyContent = { - val controllerState by controller.collectState() - CounterScreen(counterState = controllerState, action = controller::dispatch) - } + bodyContent = { CounterScreen() } ) } } @@ -61,4 +55,4 @@ internal object AppColors { secondary = secondary ) } -} \ No newline at end of file +} diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CompositionController.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CompositionController.kt new file mode 100644 index 00000000..44521317 --- /dev/null +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CompositionController.kt @@ -0,0 +1,81 @@ +package at.florianschuster.control.androidcountercomposeexample + +import androidx.compose.Composable +import androidx.compose.CompositionLifecycleObserver +import androidx.compose.State +import androidx.compose.collectAsState +import at.florianschuster.control.Controller +import at.florianschuster.control.ControllerLog +import at.florianschuster.control.ManagedController +import at.florianschuster.control.Mutator +import at.florianschuster.control.Reducer +import at.florianschuster.control.Transformer +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.emptyFlow + +/** + * Collects values from the [Controller.state] and represents its latest value via + * [androidx.compose.State]. + * + * Every time a new [Controller.state] is emitted, the returned [androidx.compose.State] + * will be updated causing re-composition of every [androidx.compose.State.value] usage. + */ +@Composable +internal fun Controller<*, *, S>.collectState(): State { + return state.collectAsState(initial = currentState) +} + +/** + * Creates a [Controller] that can be used inside a composition. + * + * Internally, a [ManagedController] is created that uses [CompositionLifecycleObserver] + * to start its state machine when [CompositionLifecycleObserver.onEnter] is called and + * cancels it when [CompositionLifecycleObserver.onLeave] is called. + */ +@Suppress("FunctionName") +@ExperimentalCoroutinesApi +@FlowPreview +internal fun CompositionController( + initialState: State, + mutator: Mutator = { _ -> emptyFlow() }, + reducer: Reducer = { _, previousState -> previousState }, + + actionsTransformer: Transformer = { it }, + mutationsTransformer: Transformer = { it }, + statesTransformer: Transformer = { it }, + + tag: String = defaultTag(), + controllerLog: ControllerLog = ControllerLog.default, + + dispatcher: CoroutineDispatcher = Dispatchers.Default +): Controller = CompositionLifecycleObserverController( + ManagedController( + initialState, mutator, reducer, + actionsTransformer, mutationsTransformer, statesTransformer, + tag, controllerLog, + dispatcher + ) +) + +private class CompositionLifecycleObserverController( + private val delegate: ManagedController +) : CompositionLifecycleObserver, Controller by delegate { + + override fun onEnter() { + delegate.start() + } + + override fun onLeave() { + delegate.cancel() + } +} + +@Suppress("NOTHING_TO_INLINE") +private inline fun defaultTag(): String { + val stackTrace = Throwable().stackTrace + check(stackTrace.size >= 2) { "Stacktrace didn't have enough elements." } + return stackTrace[1].className.split("$").first().split(".").last() +} \ No newline at end of file diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt new file mode 100644 index 00000000..e9c3e337 --- /dev/null +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt @@ -0,0 +1,55 @@ +package at.florianschuster.control.androidcountercomposeexample + +import at.florianschuster.control.Controller +import at.florianschuster.control.ControllerLog +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow + +internal typealias CounterController = Controller + +internal sealed class CounterAction { + object Increment : CounterAction() + object Decrement : CounterAction() +} + +internal sealed class CounterMutation { + object IncreaseValue : CounterMutation() + object DecreaseValue : CounterMutation() + data class SetLoading(val loading: Boolean) : CounterMutation() +} + +internal data class CounterState( + val value: Int = 0, + val loading: Boolean = false +) + +@Suppress("FunctionName") +internal fun CounterController( + initialState: CounterState = CounterState() +): CounterController = CompositionController( + initialState = initialState, + mutator = { action -> + when (action) { + CounterAction.Increment -> flow { + emit(CounterMutation.SetLoading(true)) + delay(500) + emit(CounterMutation.IncreaseValue) + emit(CounterMutation.SetLoading(false)) + } + CounterAction.Decrement -> flow { + emit(CounterMutation.SetLoading(true)) + delay(500) + emit(CounterMutation.DecreaseValue) + emit(CounterMutation.SetLoading(false)) + } + } + }, + reducer = { mutation, previousState -> + when (mutation) { + is CounterMutation.IncreaseValue -> previousState.copy(value = previousState.value + 1) + is CounterMutation.DecreaseValue -> previousState.copy(value = previousState.value - 1) + is CounterMutation.SetLoading -> previousState.copy(loading = mutation.loading) + } + }, + controllerLog = ControllerLog.Println +) \ No newline at end of file diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt index db1dc2ae..b2eb66b2 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt @@ -1,6 +1,8 @@ package at.florianschuster.control.androidcountercomposeexample import androidx.compose.Composable +import androidx.compose.getValue +import androidx.compose.remember import androidx.ui.core.Alignment import androidx.ui.core.Modifier import androidx.ui.core.tag @@ -16,20 +18,27 @@ import androidx.ui.material.MaterialTheme import androidx.ui.material.TextButton import androidx.ui.tooling.preview.Preview import androidx.ui.unit.dp -import at.florianschuster.control.kotlincounter.CounterAction -import at.florianschuster.control.kotlincounter.CounterState @Composable internal fun CounterScreen( + injectedController: CounterController = CounterController() +) { + val controller = remember { injectedController } + val counterState by controller.collectState() + CounterComponent(counterState, controller::dispatch) +} + +@Composable +private fun CounterComponent( counterState: CounterState, - action: (CounterAction) -> Unit = {} + dispatch: (CounterAction) -> Unit = {} ) { Stack(modifier = Modifier.fillMaxSize()) { Row( modifier = Modifier.fillMaxWidth().gravity(Alignment.Center), horizontalArrangement = Arrangement.SpaceEvenly ) { - TextButton(onClick = { action(CounterAction.Decrement) }) { + TextButton(onClick = { dispatch(CounterAction.Decrement) }) { Text(text = "-", style = MaterialTheme.typography.h4) } Text( @@ -38,7 +47,7 @@ internal fun CounterScreen( style = MaterialTheme.typography.h3, modifier = Modifier.tag("valueText") ) - TextButton(onClick = { action(CounterAction.Increment) }) { + TextButton(onClick = { dispatch(CounterAction.Increment) }) { Text(text = "+", style = MaterialTheme.typography.h4) } } @@ -53,18 +62,18 @@ internal fun CounterScreen( } } -@Preview(name = "Loading") +@Preview("not loading") @Composable -private fun CounterScreenPreviewLoading() { +private fun CounterComponentPreview() { MaterialTheme(colors = AppColors.currentColorPalette) { - CounterScreen(counterState = CounterState(value = 21, loading = true)) + CounterComponent(CounterState(value = -1, loading = false)) } } -@Preview(name = "Not Loading") +@Preview("loading") @Composable -private fun CounterScreenPreviewNotLoading() { +private fun CounterComponentPreviewLoading() { MaterialTheme(colors = AppColors.currentColorPalette) { - CounterScreen(counterState = CounterState(value = 21, loading = false)) + CounterComponent(CounterState(value = 1, loading = true)) } -} \ No newline at end of file +} diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt deleted file mode 100644 index b82c29e4..00000000 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt +++ /dev/null @@ -1,35 +0,0 @@ -package at.florianschuster.control.androidcountercomposeexample - -import androidx.compose.Composable -import androidx.compose.State -import androidx.compose.collectAsState -import androidx.compose.onDispose -import androidx.compose.remember -import at.florianschuster.control.Controller -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlin.coroutines.CoroutineContext - -/** - * Creates a [CoroutineScope] with [coroutineContext] that lives until [onDispose]. - */ -@Composable -internal fun ComposeCoroutineScope( - coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate -): CoroutineScope { - val scope = remember(coroutineContext) { CoroutineScope(coroutineContext) } - onDispose { scope.cancel() } - return scope -} - -/** - * Collects values from the [Controller.state] and represents its latest value via [State]. - * Every time state is emitted, the returned [State] will be updated causing - * re-composition of every [State.value] usage. - */ -@Composable -internal fun Controller<*, *, S>.collectState(): State { - return state.collectAsState(initial = currentState) -} \ No newline at end of file diff --git a/examples/android-counter/build.gradle.kts b/examples/android-counter/build.gradle.kts index b9b948f9..5b698f51 100644 --- a/examples/android-counter/build.gradle.kts +++ b/examples/android-counter/build.gradle.kts @@ -40,7 +40,7 @@ dependencies { implementation(Libs.flowbinding_android) implementation(Libs.flowbinding_core) implementation(Libs.lifecycle_runtime_ktx) - debugImplementation(Libs.fragment_ktx) + implementation(Libs.fragment_ktx) debugImplementation(Libs.fragment_testing) testImplementation(Libs.coroutines_test_extensions) 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 449a9230..a008dbf4 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 @@ -8,8 +8,8 @@ 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.CounterController import at.florianschuster.control.kotlincounter.CounterState import at.florianschuster.control.kotlincounter.createCounterController import at.florianschuster.control.stub @@ -21,12 +21,13 @@ import kotlin.test.assertEquals @RunWith(AndroidJUnit4::class) internal class CounterViewTest { - private lateinit var controller: CounterController + private lateinit var stub: ControllerStub @Before fun setup() { CounterView.CounterControllerProvider = { scope -> - controller = scope.createCounterController().apply { stub() } + val controller = scope.createCounterController() + stub = controller.stub() controller } launchFragmentInContainer() @@ -38,7 +39,7 @@ internal class CounterViewTest { onView(withId(R.id.increaseButton)).perform(click()) // then - assertEquals(CounterAction.Increment, controller.stub().dispatchedActions.last()) + assertEquals(CounterAction.Increment, stub.dispatchedActions.last()) } @Test @@ -47,7 +48,7 @@ internal class CounterViewTest { onView(withId(R.id.decreaseButton)).perform(click()) // then - assertEquals(CounterAction.Decrement, controller.stub().dispatchedActions.last()) + assertEquals(CounterAction.Decrement, stub.dispatchedActions.last()) } @Test @@ -56,7 +57,7 @@ internal class CounterViewTest { val testValue = 1 // when - controller.stub().emitState(CounterState(value = testValue)) + stub.emitState(CounterState(value = testValue)) // then onView(withId(R.id.valueTextView)).check(matches(withText("$testValue"))) @@ -65,7 +66,7 @@ internal class CounterViewTest { @Test fun whenStateOffersLoadingProgressBarIsVisible() { // when - controller.stub().emitState(CounterState(loading = true)) + stub.emitState(CounterState(loading = true)) // then onView(withId(R.id.loadingProgressBar)).check(matches(isDisplayed())) diff --git a/examples/android-counter/src/main/AndroidManifest.xml b/examples/android-counter/src/main/AndroidManifest.xml index a8d04f0a..c94aba2f 100644 --- a/examples/android-counter/src/main/AndroidManifest.xml +++ b/examples/android-counter/src/main/AndroidManifest.xml @@ -15,8 +15,9 @@ + - + diff --git a/examples/android-counter/src/main/kotlin/at/florianschuster/control/androidcounter/MainActivity.kt b/examples/android-counter/src/main/kotlin/at/florianschuster/control/androidcounter/MainActivity.kt index 1c925885..b3a66cd5 100644 --- a/examples/android-counter/src/main/kotlin/at/florianschuster/control/androidcounter/MainActivity.kt +++ b/examples/android-counter/src/main/kotlin/at/florianschuster/control/androidcounter/MainActivity.kt @@ -2,15 +2,16 @@ package at.florianschuster.control.androidcounter import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.commit internal class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - if (savedInstanceState != null) return - supportFragmentManager.beginTransaction() - .replace(android.R.id.content, CounterView()) - .commit() + if (savedInstanceState == null) { + supportFragmentManager.commit { + replace(android.R.id.content, CounterView()) + } + } } } \ No newline at end of file diff --git a/examples/android-github/build.gradle.kts b/examples/android-github/build.gradle.kts index b2041cdc..8ec7fb7b 100644 --- a/examples/android-github/build.gradle.kts +++ b/examples/android-github/build.gradle.kts @@ -33,6 +33,9 @@ android { sourceSets["test"].java.srcDir("src/test/kotlin") sourceSets["androidTest"].java.srcDir("src/androidTest/kotlin") sourceSets["debug"].java.srcDir("src/debug/kotlin") + packagingOptions { + exclude("META-INF/*.kotlin_module") + } } dependencies { 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 220b93f9..7e299679 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 @@ -2,16 +2,17 @@ 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 internal class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - if (savedInstanceState != null) return - supportFragmentManager.beginTransaction() - .replace(android.R.id.content, GithubView()) - .commit() + if (savedInstanceState == null) { + supportFragmentManager.commit { + replace(android.R.id.content, GithubView()) + } + } } } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index dc7f3890..ba3903f0 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.4.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-milestone-1-all.zip