diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6cf29375..34928a83 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,4 +43,5 @@ jobs: env: ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.SIGNING_PASSWORD }} ORG_GRADLE_PROJECT_signingKey: ${{ secrets.SIGNING_KEY }} - run: ./gradlew publishAllPublicationsToSonatypeRepository --stacktrace + # It's important to not upload in parallel or duplicate repos will be created + run: ./gradlew publishAllPublicationsToSonatypeRepository -Dorg.gradle.parallel=false --stacktrace diff --git a/README.md b/README.md index 4e8055fc..e9656169 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# FlowMVI 2.0 +# FlowMVI [![CI](https://github.com/respawn-app/FlowMVI/actions/workflows/ci.yml/badge.svg)](https://github.com/respawn-app/FlowMVI/actions/workflows/ci.yml) ![License](https://img.shields.io/github/license/respawn-app/flowMVI) @@ -26,7 +26,9 @@ FlowMVI is a Kotlin Multiplatform MVI library based on coroutines that has a few * Latest version: [![Maven Central](https://img.shields.io/maven-central/v/pro.respawn.flowmvi/core?label=Maven%20Central)](https://central.sonatype.com/namespace/pro.respawn.flowmvi) -### Version Catalogs +
+Version catalogs + ```toml [versions] flowmvi = "< Badge above 👆🏻 >" @@ -40,7 +42,12 @@ flowmvi-android = { module = "pro.respawn.flowmvi:android", version.ref = "flowm flowmvi-view = { module = "pro.respawn.flowmvi:android-view", version.ref = "flowmvi" } # view-based android flowmvi-savedstate = { module = "pro.respawn.flowmvi:savedstate", version.ref = "flowmvi" } # KMP state preservation ``` -### Kotlin DSL + +
+ +
+Gradle DSL + ```kotlin dependencies { val flowmvi = "< Badge above 👆🏻 >" @@ -54,9 +61,29 @@ dependencies { } ``` -## Features: +
-Rich store DSL with dozens of useful pre-made plugins: +## Why FlowMVI? + +* Fully async and parallel business logic - with no manual thread synchronization required! +* Automatically recover from any errors and avoid runtime crashes with one line of code +* Build fully-multiplatform business logic with pluggable UI +* Create compile-time safe state machines with a readable DSL. Forget about `state as? ...` casts +* Automatic platform-independent system lifecycle handling with hooks on subscription +* Restartable, reusable stores with no external dependencies or dedicated lifecycles. +* Compress, persist, and restore state automatically with a single line of code - on any platform +* Out of the box debugging, logging, testing and long-running task management support +* Decompose stores into plugins, split responsibilities, and modularize the project easily +* No base classes or complicated interfaces - store is built using a simple DSL +* Use both MVVM+ (functional) or MVI (model-driven) style of programming +* Share, distribute, or disable side-effects based on your team's needs +* Create parent-child relationships between stores and delegate responsibilities +* 70+% unit test coverage of core library code + +## How does it look? + +
+Define a contract ```kotlin sealed interface CounterState : MVIState { @@ -77,54 +104,59 @@ sealed interface CounterIntent : MVIIntent { sealed interface CounterAction : MVIAction { data class ShowMessage(val message: String) : CounterAction } +``` +
+ +```kotlin class CounterContainer( private val repo: CounterRepository, ) { val store = store(initial = Loading) { - name = "CounterStore" parallelIntents = true - coroutineContext = Dispatchers.Default // run all operations on background threads if needed - actionShareBehavior = ActionShareBehavior.Distribute() // disable, share, distribute or consume side effects + coroutineContext = Dispatchers.Default + actionShareBehavior = ActionShareBehavior.Distribute() intentCapacity = 64 install( - platformLoggingPlugin(), // log to console, logcat or NSLog - analyticsPlugin(name), // create custom plugins - timeTravelPlugin(), // unit test stores and track changes + // log all store activity to console, logcat or NSLog + platformLoggingPlugin(), + // unit test stores and track changes + timeTravelPlugin(), + // undo and redo any actions + undoRedoPlugin(), ) - // one-liner for persisting and restoring compressed state to/from files, - // bundles, or anywhere + // manage named job + val jobManager = manageJobs() + + // persist and restore state serializeState( dir = repo.cacheDir, json = Json, serializer = DisplayingCounter.serializer(), - recover = ThrowRecover ) - val undoRedoPlugin = undoRedo(maxQueueSize = 10) // undo and redo any changes - - val jobManager = manageJobs() // manage named jobs + // run actions when store is launched + init { repo.startTimer() } - init { // run actions when store is launched - repo.startTimer() + // recover from errors both in jobs and plugins + recover { e: Exception -> + action(ShowMessage(e.message)) + null } - whileSubscribed { // run a job while any subscribers are present - repo.timer.onEach { timer: Int -> - updateState { // update state safely between threads and filter by type + // run jobs while subscribers are present + whileSubscribed { + repo.timer.collect { + updateState { copy(timer = timer) } - }.consume() - } - - recover { e: Exception -> // recover from errors both in jobs and plugins - action(ShowMessage(e.message)) // send side-effects - null + } } - reduce { intent: CounterIntent -> // reduce intents + // install, split, and decompose reducers + reduce { intent: CounterIntent -> when (intent) { is ClickedCounter -> updateState { copy(counter = counter + 1) @@ -132,26 +164,22 @@ class CounterContainer( } } - parentStore(repo.store) { state -> // one-liner to attach to any other store. + // one-liner to attach to any other store. + parentStore(repo.store) { state -> updateState { copy(timer = state.timer) } } - install { // build and install custom plugins on the fly + // lazily evaluate and cache values, even when the method is suspending. + val pagingData by cache { + repo.getPagedDataSuspending() + } + install { // build and install custom plugins on the fly onStop { // hook into various store events repo.stopTimer() } - - onState { old, new -> // veto changes, modify states, launch jobs, do literally anything - new.withType { - if (counter >= 100) { - launch { repo.resetTimer() } - copy(counter = 0, timer = 0) - } else new - } - } } } } @@ -161,7 +189,7 @@ class CounterContainer( ```kotlin store.subscribe( - scope = consumerCoroutineScope, + scope = coroutineScope, consume = { action -> /* process side effects */ }, render = { state -> /* render states */ }, ) @@ -169,8 +197,9 @@ store.subscribe( ### Custom plugins: +Create plugins with a single line of code for any store or a specific one and hook into all store events: + ```kotlin -// Create plugins with a single line of code for any store or a specific one val counterPlugin = plugin { onStart { /*...*/ @@ -188,9 +217,9 @@ val counterPlugin = plugin { ```kotlin @Composable fun CounterScreen() { - val store = remember { CounterContainer() } // or use a DI framework + val store = inject() - // collect the state and handle events efficiently based on system lifecycle, whether it's iOS or Desktop + // collect the state and handle events efficiently based on system lifecycle - on any platform val state by store.subscribe { action -> when (action) { is ShowMessage -> { @@ -224,7 +253,6 @@ class ScreenFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - // One-liner for store subscription. Lifecycle-aware and efficient. subscribe(vm, ::consume, ::render) } @@ -238,11 +266,12 @@ class ScreenFragment : Fragment() { } ``` -### Testing DSL +## Testing DSL + +### Test Stores ```kotlin -// using Turbine + Kotest -testStore().subscribeAndTest { +counterStore().subscribeAndTest { ClickedCounter resultsIn { states.test { @@ -255,6 +284,20 @@ testStore().subscribeAndTest { } ``` +### Test plugins + +```kotlin +val timer = Timer() +timerPlugin(timer).test(Loading) { + onStart() + assert(timeTravel.starts == 1) // keeps track of all plugin operations + assert(state is DisplayingCounter) + assert(timer.isStarted) + onStop(null) + assert(!timer.isStarted) +} +``` + Ready to try? Start with reading the [Quickstart Guide](https://opensource.respawn.pro/FlowMVI/#/quickstart). ## License diff --git a/android/src/main/kotlin/pro/respawn/flowmvi/android/plugins/SavedStatePlugin.kt b/android/src/main/kotlin/pro/respawn/flowmvi/android/plugins/SavedStatePlugin.kt index ff54cdf4..3a71b6c8 100644 --- a/android/src/main/kotlin/pro/respawn/flowmvi/android/plugins/SavedStatePlugin.kt +++ b/android/src/main/kotlin/pro/respawn/flowmvi/android/plugins/SavedStatePlugin.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package pro.respawn.flowmvi.android.plugins import android.os.Parcelable diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt index cae10a6e..d03023ae 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt @@ -17,9 +17,9 @@ sealed interface CounterState : MVIState { @Serializable @Parcelize data class DisplayingCounter( - val timer: Int, - val counter: Int, - val input: String, + val timer: Int = 0, + val counter: Int = 0, + val input: String = "", ) : CounterState, Parcelable } diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt index 76ef77bc..9422cdb1 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt @@ -28,7 +28,7 @@ import pro.respawn.flowmvi.sample.CounterIntent.InputChanged import pro.respawn.flowmvi.sample.CounterState import pro.respawn.flowmvi.sample.CounterState.DisplayingCounter import pro.respawn.flowmvi.sample.repository.CounterRepository -import pro.respawn.flowmvi.savedstate.api.ThrowRecover +import pro.respawn.flowmvi.savedstate.api.NullRecover import pro.respawn.flowmvi.savedstate.plugins.serializeState import pro.respawn.flowmvi.util.typed import kotlin.random.Random @@ -54,7 +54,7 @@ class CounterContainer( dir = cacheDir, json = json, serializer = DisplayingCounter.serializer(), - recover = ThrowRecover + recover = NullRecover, ) val undoRedo = undoRedo(10) val jobManager = manageJobs() @@ -104,7 +104,7 @@ class CounterContainer( } private suspend fun Ctx.produceState(timer: Int) = updateState { - // remember that you have to merge states when you are running produceState + // merge states val current = typed() DisplayingCounter( timer = timer, diff --git a/build.gradle.kts b/build.gradle.kts index 33e5212f..ab1dcb30 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ -import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask import nl.littlerobots.vcu.plugin.versionCatalogUpdate +import nl.littlerobots.vcu.plugin.versionSelector import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnLockMismatchReport +import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnPlugin import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnRootExtension import org.jetbrains.kotlin.gradle.tasks.KotlinCompile @@ -9,7 +10,6 @@ private val PluginPrefix = "plugin:androidx.compose.compiler.plugins.kotlin:repo plugins { alias(libs.plugins.detekt) alias(libs.plugins.gradleDoctor) - alias(libs.plugins.versions) alias(libs.plugins.version.catalog.update) alias(libs.plugins.dokka) alias(libs.plugins.atomicfu) @@ -27,8 +27,8 @@ allprojects { version = Config.versionName tasks.withType().configureEach { compilerOptions { - jvmTarget.set(Config.jvmTarget) - languageVersion.set(Config.kotlinVersion) + jvmTarget = Config.jvmTarget + languageVersion = Config.kotlinVersion freeCompilerArgs.apply { addAll(Config.jvmCompilerArgs) if (project.findProperty("enableComposeCompilerReports") == "true") { @@ -89,12 +89,14 @@ dependencies { } versionCatalogUpdate { - sortByKey.set(true) + sortByKey = true + + versionSelector { stabilityLevel(it.candidate.version) >= Config.minStabilityLevel } keep { - keepUnusedVersions.set(true) - keepUnusedLibraries.set(true) - keepUnusedPlugins.set(true) + keepUnusedVersions = true + keepUnusedLibraries = true + keepUnusedPlugins = true } } @@ -133,26 +135,12 @@ tasks { description = "Run detekt on whole project" autoCorrect = false } - - withType().configureEach { - outputFormatter = "json" - - fun stabilityLevel(version: String): Int { - Config.stabilityLevels.forEachIndexed { index, postfix -> - val regex = """.*[.\-]$postfix[.\-\d]*""".toRegex(RegexOption.IGNORE_CASE) - if (version.matches(regex)) return index - } - return Config.stabilityLevels.size - } - - rejectVersionIf { - stabilityLevel(currentVersion) > stabilityLevel(candidate.version) - } - } } -extensions.findByType()?.run { - yarnLockMismatchReport = YarnLockMismatchReport.WARNING - reportNewYarnLock = true - yarnLockAutoReplace = false +rootProject.plugins.withType().configureEach { + rootProject.the().apply { + yarnLockMismatchReport = YarnLockMismatchReport.WARNING // NONE | FAIL | FAIL_AFTER_BUILD + reportNewYarnLock = false // true + yarnLockAutoReplace = false // true + } } diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 980b227d..9759b238 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -16,17 +16,15 @@ object Config { const val artifactId = "$group.$artifact" const val majorRelease = 2 - const val minorRelease = 3 + const val minorRelease = 4 const val patch = 0 - const val postfix = "rc" - const val versionName = "$majorRelease.$minorRelease.$patch-$postfix" + const val postfix = "" // include dash (-) + const val versionName = "$majorRelease.$minorRelease.$patch$postfix" const val url = "https://github.com/respawn-app/FlowMVI" const val licenseName = "The Apache Software License, Version 2.0" const val licenseUrl = "http://www.apache.org/licenses/LICENSE-2.0.txt" const val scmUrl = "https://github.com/respawn-app/FlowMVI.git" - const val description = """ -A Kotlin Multiplatform MVI library based on plugins that is simple, powerful & flexible -""" + const val description = """A Kotlin Multiplatform MVI library based on plugins with a powerful plugin system""" // kotlin val optIns = listOf( @@ -45,7 +43,7 @@ A Kotlin Multiplatform MVI library based on plugins that is simple, powerful & f add("-Xcontext-receivers") add("-Xstring-concat=inline") add("-P") - add("plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=false") + add("plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true") addAll(optIns.map { "-opt-in=$it" }) } @@ -67,7 +65,10 @@ A Kotlin Multiplatform MVI library based on plugins that is simple, powerful & f const val proguardFile = "proguard-rules.pro" const val consumerProguardFile = "consumer-rules.pro" - val stabilityLevels = listOf("preview", "eap", "alpha", "beta", "m", "cr", "rc") + // position reflects the level of stability, order is important + val stabilityLevels = listOf("snapshot", "eap", "preview", "alpha", "beta", "m", "cr", "rc") + val minStabilityLevel = stabilityLevels.indexOf("beta") + object Detekt { const val configFile = "detekt.yml" diff --git a/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt b/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt index a5a336da..4e16e0e0 100644 --- a/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt +++ b/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt @@ -37,7 +37,7 @@ fun Project.configureMultiplatform( if (android) { androidTarget { - publishAllLibraryVariants() + publishLibraryVariants("release") } } diff --git a/buildSrc/src/main/kotlin/Util.kt b/buildSrc/src/main/kotlin/Util.kt index 1b2eb1ef..89745cc3 100644 --- a/buildSrc/src/main/kotlin/Util.kt +++ b/buildSrc/src/main/kotlin/Util.kt @@ -1,9 +1,4 @@ -@file:Suppress( - "MemberVisibilityCanBePrivate", - "MissingPackageDeclaration", - "UndocumentedPublicProperty", - "UndocumentedPublicFunction" -) +@file:Suppress("MissingPackageDeclaration", "UndocumentedPublicFunction", "UndocumentedPublicProperty") import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalog @@ -56,7 +51,13 @@ fun String.toBase64() = Base64.getEncoder().encodeToString(toByteArray()) val Project.localProperties get() = lazy { - Properties().apply { - load(FileInputStream(File(rootProject.rootDir, "local.properties"))) - } + Properties().apply { load(FileInputStream(File(rootProject.rootDir, "local.properties"))) } + } + +fun stabilityLevel(version: String): Int { + Config.stabilityLevels.forEachIndexed { index, postfix -> + val regex = """.*[.\-]$postfix[.\-\d]*""".toRegex(RegexOption.IGNORE_CASE) + if (version.matches(regex)) return index } + return Config.stabilityLevels.size +} diff --git a/core/src/appleMain/kotlin/pro/respawn/flowmvi/store/NativeStore.kt b/core/src/appleMain/kotlin/pro/respawn/flowmvi/store/NativeStore.kt index 19568279..21883a8b 100644 --- a/core/src/appleMain/kotlin/pro/respawn/flowmvi/store/NativeStore.kt +++ b/core/src/appleMain/kotlin/pro/respawn/flowmvi/store/NativeStore.kt @@ -21,12 +21,7 @@ public class NativeStore( private val store: Store, autoStart: Boolean = false, private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main), -) : AutoCloseable { - - /** - * Get the name of the store. Changed using [pro.respawn.flowmvi.dsl.StoreBuilder] - */ - public val name: String? = store.name +) : Store by store, CoroutineScope by scope { init { if (autoStart) store.start(scope) @@ -44,24 +39,5 @@ public class NativeStore( override fun close() = job.cancel() } - /** - * See [pro.respawn.flowmvi.api.IntentReceiver.send] - */ - public fun send(intent: I): Unit = store.intent(intent) - - /** - * See [pro.respawn.flowmvi.api.IntentReceiver.send] - */ - public fun intent(intent: I): Unit = store.intent(intent) - - /** - * Stop the store, but do not cancel the scope - */ - override fun close(): Unit = store.close() - - /** - * Close the store, all subscribers, and the parent scope. NativeStore object **cannot** be used after this! - * @see close - */ - public fun cancel(): Unit = scope.cancel() + override fun close(): Unit = cancel() } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StorePluginBuilder.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StorePluginBuilder.kt index 0fbf311e..4ffa9073 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StorePluginBuilder.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StorePluginBuilder.kt @@ -12,6 +12,8 @@ import pro.respawn.flowmvi.plugins.AbstractStorePlugin * A class that builds a new [StorePlugin] * For more documentation, see [StorePlugin] */ + +@Suppress("DEPRECATION") public class StorePluginBuilder @PublishedApi internal constructor() { private var intent: suspend PipelineContext.(I) -> I? = { it } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/SubscribeDsl.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/SubscribeDsl.kt index 0005365e..d60feddf 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/SubscribeDsl.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/SubscribeDsl.kt @@ -10,9 +10,24 @@ import pro.respawn.flowmvi.api.ImmutableStore import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.Provider import pro.respawn.flowmvi.api.StateConsumer import pro.respawn.flowmvi.api.Store +/** + * Subscribe to [this] store and suspend until [consume] finishes (which should never return). + * This means the function will suspend forever. + * @see subscribe for non-suspending variant + */ +@FlowMVIDSL +public suspend inline fun ImmutableStore.collect( + @BuilderInference crossinline consume: suspend Provider.() -> Unit, +): Unit = coroutineScope { + subscribe { + consume() + }.join() +} + /** * Subscribe to the [store] and invoke [consume] and [render] in parallel in the provided scope. * diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PluginModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PluginModule.kt deleted file mode 100644 index e5da0831..00000000 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PluginModule.kt +++ /dev/null @@ -1,36 +0,0 @@ -package pro.respawn.flowmvi.modules - -import pro.respawn.flowmvi.api.MVIAction -import pro.respawn.flowmvi.api.MVIIntent -import pro.respawn.flowmvi.api.MVIState -import pro.respawn.flowmvi.api.PipelineContext -import pro.respawn.flowmvi.api.StorePlugin -import pro.respawn.flowmvi.plugins.AbstractStorePlugin - -internal fun pluginModule( - plugins: Set> -): StorePlugin = PluginModule(plugins) - -private class PluginModule( - private val plugins: Set>, -) : AbstractStorePlugin(null) { - - override suspend fun PipelineContext.onStart(): Unit = plugins { onStart() } - override fun onStop(e: Exception?): Unit = plugins { onStop(e) } - override suspend fun PipelineContext.onState(old: S, new: S): S? = plugins(new) { onState(old, it) } - override suspend fun PipelineContext.onIntent(intent: I): I? = plugins(intent) { onIntent(it) } - override suspend fun PipelineContext.onAction(action: A): A? = plugins(action) { onAction(it) } - override suspend fun PipelineContext.onException(e: Exception): Exception? = plugins(e) { onException(it) } - override suspend fun PipelineContext.onUnsubscribe(subscriberCount: Int) = - plugins { onUnsubscribe(subscriberCount) } - - override suspend fun PipelineContext.onSubscribe( - subscriberCount: Int - ) = plugins { onSubscribe(subscriberCount) } - - private inline fun plugins(block: StorePlugin.() -> Unit) = plugins.forEach(block) - private inline fun plugins( - initial: R, - block: StorePlugin.(R) -> R? - ) = plugins.fold<_, R?>(initial) { acc, it -> it.block(acc ?: return@plugins acc) } -} diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/AbstractStorePlugin.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/AbstractStorePlugin.kt index af422f4e..8031ed80 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/AbstractStorePlugin.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/AbstractStorePlugin.kt @@ -6,26 +6,31 @@ import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.StorePlugin /** - * A base class for creating custom [StorePlugin]s. This class is preferred over implementing the interface. - * Use this class when you want to build reusable plugins, inject dependencies, - * or want to have the reference to the plugin's instance and use it outside of its regular pipeline. - * For all other cases, prefer [pro.respawn.flowmvi.dsl.plugin] builder function. + * A base class for creating custom [StorePlugin]s. * - * It is preferred to use composition instead of inheriting this class however. - * For an example, see how a [jobManagerPlugin] ([JobManager] is implemented. + * It is preferred to use composition instead of inheriting this class. + * Prefer [pro.respawn.flowmvi.dsl.plugin] builder function instead of extending this class. + * For an example, see how a [jobManagerPlugin] ([JobManager]) is implemented. * * @see [StorePlugin] * @see [pro.respawn.flowmvi.dsl.plugin] */ +@Deprecated( + """ +Plugin builders provide sufficient functionality to use them instead of this class. +Extending this class limits your API and leaks lifecycle methods of a plugin to external code. +This class will become internal in future releases of the library. +""" +) public abstract class AbstractStorePlugin( final override val name: String? = null, ) : StorePlugin { final override fun toString(): String = "StorePlugin \"${name ?: super.toString()}\"" final override fun hashCode(): Int = name?.hashCode() ?: super.hashCode() - final override fun equals(other: Any?): Boolean { - if (other !is StorePlugin<*, *, *>) return false - if (other.name == null && name == null) return this === other - return name == other.name + final override fun equals(other: Any?): Boolean = when { + other !is StorePlugin<*, *, *> -> false + other.name == null && name == null -> this === other + else -> name == other.name } } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/CachePlugin.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/CachePlugin.kt index a75b13c3..657421f6 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/CachePlugin.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/CachePlugin.kt @@ -7,7 +7,9 @@ import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.PipelineContext +import pro.respawn.flowmvi.api.StorePlugin import pro.respawn.flowmvi.dsl.StoreBuilder +import pro.respawn.flowmvi.dsl.plugin import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KProperty @@ -52,14 +54,13 @@ and the plugin that caches the value was installed before first access. * @see cache * @see cachePlugin */ -public class CachePlugin internal constructor( - name: String? = null, +public class CachedValue internal constructor( private val init: suspend PipelineContext.() -> T, -) : AbstractStorePlugin(name), ReadOnlyProperty { +) : ReadOnlyProperty { private data object UNINITIALIZED { - override fun toString() = "Uncached value" + override fun toString() = "Uninitialized cache value" } private var _value = atomic(UNINITIALIZED) @@ -69,36 +70,60 @@ public class CachePlugin inte */ public val isCached: Boolean get() = _value.value !== UNINITIALIZED - override suspend fun PipelineContext.onStart(): Unit = _value.update { init() } + /** + * Obtain the value. + * **The value can only be accessed before [StorePlugin.onStart] and [StorePlugin.onStop], otherwise this function + * will throw!** + */ + public val value: T + @Suppress("UNCHECKED_CAST") get() { + require(isCached) { AccessBeforeCachingMessage } + return _value.value as T + } + + override operator fun getValue(thisRef: Any?, property: KProperty<*>): T = value - override fun onStop(e: Exception?): Unit = _value.update { UNINITIALIZED } + internal fun asPlugin(name: String?) = plugin { + this.name = name - @Suppress("UNCHECKED_CAST") - override fun getValue(thisRef: Any?, property: KProperty<*>): T { - require(isCached) { AccessBeforeCachingMessage } - return _value.value as T + onStart { _value.update { init() } } + + onStop { _value.update { UNINITIALIZED } } } } /** - * Creates and returns a new [CachePlugin] without installing it. - * @see CachePlugin + * Creates and returns a new [CachedValue]. + * @see cachePlugin + */ +public fun cached( + @BuilderInference init: suspend PipelineContext.() -> T, +): CachedValue = CachedValue(init) + +/** + * Converts [value] to a [StorePlugin]. + * Mostly needed when storing a direct reference to the [CachedValue] out of the store's scope (which is less safe). + * For all other cases, use [cache]. + * + * @see cache + * @see cached */ @FlowMVIDSL public fun cachePlugin( + value: CachedValue, name: String? = null, - @BuilderInference init: suspend PipelineContext.() -> T, -): CachePlugin = CachePlugin(name, init) +): StorePlugin = value.asPlugin(name) /** - * Creates and installs a new [CachePlugin], returning a delegate that can be used to get access to the property that + * Creates and installs a new [CachedValue], returning a delegate that can be used to get access to the property that * was cached. Please consult the documentation of the parent class to understand how to use this plugin. * - * @return A [ReadOnlyProperty] granting access to the value returned from [init] + * @return A [CachedValue] granting access to the value returned from [init] * @see cachePlugin + * @see cached */ @FlowMVIDSL public fun StoreBuilder.cache( name: String? = null, @BuilderInference init: suspend PipelineContext.() -> T, -): ReadOnlyProperty = cachePlugin(name, init).also { install(it) } +): CachedValue = CachedValue(init).also { install(it.asPlugin(name)) } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/CompositePlugin.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/CompositePlugin.kt new file mode 100644 index 00000000..9bb271da --- /dev/null +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/CompositePlugin.kt @@ -0,0 +1,38 @@ +package pro.respawn.flowmvi.plugins + +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.StorePlugin +import pro.respawn.flowmvi.dsl.plugin + +/** + * A plugin that delegates to [plugins] in the iteration order. + * This is an implementation of the "Composite" pattern and the "Chain or Responsibility" pattern. + * + * This plugin is mostly not intended for usage in general code as there are no real use cases for it so far. + * It can be useful in testing and custom store implementations. + */ +public fun compositePlugin( + plugins: Set>, + name: String? = null, +): StorePlugin = plugin { + this.name = name + onState { old: S, new: S -> plugins.fold(new) { onState(old, it) } } + onIntent { intent: I -> plugins.fold(intent) { onIntent(it) } } + onAction { action: A -> plugins.fold(action) { onAction(it) } } + onException { e: Exception -> plugins.fold(e) { onException(it) } } + onUnsubscribe { subs: Int -> plugins.fold { onUnsubscribe(subs) } } + onSubscribe { subs: Int -> plugins.fold { onSubscribe(subs) } } + onStart { plugins.fold { onStart() } } + onStop { plugins.fold { onStop(it) } } +} + +private inline fun Iterable>.fold( + block: StorePlugin.() -> Unit, +) = forEach(block) + +private inline fun Iterable>.fold( + initial: R, + block: StorePlugin.(R) -> R? +) = fold<_, R?>(initial) inner@{ acc, it -> it.block(acc ?: return@fold acc) } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/JobManagerPlugin.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/JobManagerPlugin.kt index fbd4bf33..e2380f25 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/JobManagerPlugin.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/JobManagerPlugin.kt @@ -1,6 +1,7 @@ package pro.respawn.flowmvi.plugins import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable.start import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancelAndJoin @@ -134,6 +135,11 @@ public class JobManager { */ public suspend fun join(key: K): Job? = jobs[key]?.apply { join() } + /** + * Joins all jobs specified in [keys] in the declaration order + */ + public suspend fun joinAll(vararg keys: K): Unit = keys.forEach { join(it) } + /** * Start the job with [key] if it is present. * @return the job tha was started or null if not found. diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/LoggingPlugin.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/LoggingPlugin.kt index 70946c51..1e964924 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/LoggingPlugin.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/LoggingPlugin.kt @@ -47,6 +47,7 @@ public inline fun loggingPlugin( crossinline log: (level: StoreLogLevel, tag: String, msg: String) -> Unit, ): StorePlugin = plugin { this.name = name + log(Info, tag ?: name, "Initialized logging") onState { old, new -> log(Trace, tag ?: name, "\nState:\n--->\n$old\n<---\n$new") new @@ -60,7 +61,7 @@ public inline fun loggingPlugin( it } onException { - log(Error, tag ?: name, "Exception:\n $it") + log(Error, tag ?: name, "Exception:\n ${it.stackTraceToString()}") it } onStart { diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/SavedStatePlugin.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/SavedStatePlugin.kt index 97d4bafb..5a171afa 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/SavedStatePlugin.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/SavedStatePlugin.kt @@ -48,6 +48,7 @@ public inline fun savedStatePlugin( * Creates and installs a new [savedStatePlugin]. */ @FlowMVIDSL +@Suppress("DEPRECATION") @Deprecated("If you want to save state, use the new `savedstate` module dependency") public inline fun StoreBuilder.saveState( name: String = DefaultSavedStatePluginName, diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TimeTravelPlugin.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TimeTravelPlugin.kt index aead0232..4a55babd 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TimeTravelPlugin.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TimeTravelPlugin.kt @@ -5,8 +5,9 @@ import pro.respawn.flowmvi.api.FlowMVIDSL import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState -import pro.respawn.flowmvi.api.PipelineContext +import pro.respawn.flowmvi.api.StorePlugin import pro.respawn.flowmvi.dsl.StoreBuilder +import pro.respawn.flowmvi.dsl.plugin import pro.respawn.flowmvi.util.CappedMutableList /** @@ -15,13 +16,12 @@ import pro.respawn.flowmvi.util.CappedMutableList * Keep a reference to this plugin and use it to enable custom time travel support or validate the store's behavior * in tests. */ -public class TimeTravelPlugin internal constructor( - name: String, - maxStates: Int, - maxIntents: Int, - maxActions: Int, - maxExceptions: Int, -) : AbstractStorePlugin(name) { +public class TimeTravel( + maxStates: Int = DefaultHistorySize, + maxIntents: Int = DefaultHistorySize, + maxActions: Int = DefaultHistorySize, + maxExceptions: Int = DefaultHistorySize, +) { private val _states by atomic(CappedMutableList(maxStates)) @@ -57,19 +57,19 @@ public class TimeTravelPlugin intern * The last value is the most recent. */ public var subscriptions: Int by atomic(0) - internal set + private set /** * Number of times the store was launched. Never decreases. */ public var starts: Int by atomic(0) - internal set + private set /** * Number of the times the store was stopped. Never decreases. */ public var stops: Int by atomic(0) - internal set + private set /** * Number of times the store has been unsubscribed from. Never decreases. @@ -91,28 +91,16 @@ public class TimeTravelPlugin intern stops = 0 } - override suspend fun PipelineContext.onState(old: S, new: S): S = new.also { _states.add(it) } - - override suspend fun PipelineContext.onIntent(intent: I): I = intent.also { _intents.add(it) } - - override suspend fun PipelineContext.onAction(action: A): A = action.also { _actions.add(it) } - - override suspend fun PipelineContext.onException(e: Exception): Exception = e.also { _exceptions.add(it) } - - override suspend fun PipelineContext.onStart() { - starts += 1 - } - - override suspend fun PipelineContext.onSubscribe(subscriberCount: Int) { - subscriptions += 1 - } - - override suspend fun PipelineContext.onUnsubscribe(subscriberCount: Int) { - unsubscriptions += 1 - } - - override fun onStop(e: Exception?) { - stops += 1 + internal fun asPlugin(name: String) = plugin { + this.name = name + onState { _: S, new: S -> new.also { _states.add(new) } } + onIntent { intent: I -> intent.also { _intents.add(it) } } + onAction { action: A -> action.also { _actions.add(it) } } + onException { e: Exception -> e.also { _exceptions.add(it) } } + onStart { starts += 1 } + onSubscribe { subscriptions += 1 } + onUnsubscribe { unsubscriptions += 1 } + onStop { stops += 1 } } public companion object { @@ -130,33 +118,21 @@ public class TimeTravelPlugin intern } /** - * Create a new [TimeTravelPlugin]. Keep a reference to the plugin to use its properties. + * Create a new [TimeTravel]. Keep a reference to the plugin to use its properties. * @return the plugin. */ @FlowMVIDSL public fun timeTravelPlugin( - name: String = TimeTravelPlugin.Name, - maxStates: Int = TimeTravelPlugin.DefaultHistorySize, - maxIntents: Int = TimeTravelPlugin.DefaultHistorySize, - maxActions: Int = TimeTravelPlugin.DefaultHistorySize, - maxExceptions: Int = TimeTravelPlugin.DefaultHistorySize, -): TimeTravelPlugin = TimeTravelPlugin(name, maxStates, maxIntents, maxActions, maxExceptions) + timeTravel: TimeTravel, + name: String = TimeTravel.Name, +): StorePlugin = timeTravel.asPlugin(name) /** - * Create a new [TimeTravelPlugin] and installs it. Keep a reference to the plugin to use its properties. + * Create a new [TimeTravel] and installs it. Keep a reference to the plugin to use its properties. * @return the plugin. */ @FlowMVIDSL public fun StoreBuilder.timeTravel( - name: String = TimeTravelPlugin.Name, - maxStates: Int = TimeTravelPlugin.DefaultHistorySize, - maxIntents: Int = TimeTravelPlugin.DefaultHistorySize, - maxActions: Int = TimeTravelPlugin.DefaultHistorySize, - maxExceptions: Int = TimeTravelPlugin.DefaultHistorySize, -): TimeTravelPlugin = timeTravelPlugin( - name = name, - maxStates = maxStates, - maxIntents = maxIntents, - maxActions = maxActions, - maxExceptions = maxExceptions, -).also { install(it) } + timeTravel: TimeTravel, + name: String = TimeTravel.Name, +): Unit = install(timeTravelPlugin(timeTravel, name = name)) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/UndoRedoPlugin.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/UndoRedoPlugin.kt index fede3204..f6dadd83 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/UndoRedoPlugin.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/UndoRedoPlugin.kt @@ -8,25 +8,25 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import pro.respawn.flowmvi.api.FlowMVIDSL +import pro.respawn.flowmvi.api.IntentReceiver import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState -import pro.respawn.flowmvi.api.PipelineContext +import pro.respawn.flowmvi.api.StorePlugin import pro.respawn.flowmvi.dsl.StoreBuilder +import pro.respawn.flowmvi.dsl.plugin import pro.respawn.flowmvi.util.CappedMutableList /** * A plugin that allows to undo and redo any actions happening in the [pro.respawn.flowmvi.api.Store]. * Keep a reference to the plugin instance to call [undo], [redo], and [invoke]. */ -public class UndoRedoPlugin( +public class UndoRedo( private val maxQueueSize: Int, - name: String? = null, - private val resetOnException: Boolean, -) : AbstractStorePlugin(name) { +) { init { - require(maxQueueSize > 0) { "Queue size less than 1 is not possible, you provided: $maxQueueSize" } + require(maxQueueSize > 0) { "Queue size less than 1 is not allowed, you provided: $maxQueueSize" } } private val queue by atomic>(CappedMutableList(maxQueueSize)) @@ -86,7 +86,7 @@ public class UndoRedoPlugin( * Add the [intent] to the queue with specified [undo] and **immediately** execute the [intent]. * **You cannot call [UndoRedoPlugin.undo] or [UndoRedoPlugin.redo] in [intent] or [undo]!** */ - public suspend operator fun PipelineContext.invoke( + public suspend operator fun IntentReceiver.invoke( intent: I, undo: suspend () -> Unit, ): Int = invoke(redo = { intent(intent) }, undo = undo, doImmediately = true) @@ -129,11 +129,15 @@ public class UndoRedoPlugin( -1 } - // reset because pipeline context captured in Events is no longer running - override suspend fun PipelineContext.onStart(): Unit = reset() - override fun onStop(e: Exception?): Unit = reset() - override suspend fun PipelineContext.onException(e: Exception): Exception = - e.also { if (resetOnException) reset() } + internal fun asPlugin( + name: String?, + resetOnException: Boolean, + ): StorePlugin = plugin { + this.name = name + // reset because pipeline context captured in Events is no longer running + onStop { reset() } + if (resetOnException) onException { it.also { reset() } } + } /** * An event happened in the [UndoRedoPlugin]. @@ -148,22 +152,24 @@ public class UndoRedoPlugin( } /** - * Creates a new [UndoRedoPlugin] + * Returns a plugin that manages the [undoRedo] provided */ @FlowMVIDSL public fun undoRedoPlugin( - maxQueueSize: Int, + undoRedo: UndoRedo, name: String? = null, resetOnException: Boolean = true, -): UndoRedoPlugin = UndoRedoPlugin(maxQueueSize, name, resetOnException) +): StorePlugin = undoRedo.asPlugin(name, resetOnException) /** - * Creates and installs a new [UndoRedoPlugin] - * @return an instance that was created. Make sure to keep it to use the plugin's api. + * Creates, installs and returns a new [UndoRedo] instance + * @return an instance that was created. Use the returned instance to execute undo and redo operations. + * @see UndoRedo + * @see undoRedoPlugin */ @FlowMVIDSL public fun StoreBuilder.undoRedo( maxQueueSize: Int, name: String? = null, resetOnException: Boolean = true, -): UndoRedoPlugin = UndoRedoPlugin(maxQueueSize, name, resetOnException).also { install(it) } +): UndoRedo = UndoRedo(maxQueueSize).also { install(it.asPlugin(name, resetOnException)) } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/store/StoreImpl.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/store/StoreImpl.kt index a448a01d..69168262 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/store/StoreImpl.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/store/StoreImpl.kt @@ -15,6 +15,7 @@ import pro.respawn.flowmvi.api.PipelineContext import pro.respawn.flowmvi.api.Provider import pro.respawn.flowmvi.api.Store import pro.respawn.flowmvi.api.StorePlugin +import pro.respawn.flowmvi.api.UnrecoverableException import pro.respawn.flowmvi.exceptions.NonSuspendingSubscriberException import pro.respawn.flowmvi.exceptions.UnhandledIntentException import pro.respawn.flowmvi.exceptions.UnhandledStoreException @@ -27,16 +28,16 @@ import pro.respawn.flowmvi.modules.actionModule import pro.respawn.flowmvi.modules.intentModule import pro.respawn.flowmvi.modules.launchPipeline import pro.respawn.flowmvi.modules.observeSubscribers -import pro.respawn.flowmvi.modules.pluginModule import pro.respawn.flowmvi.modules.stateModule import pro.respawn.flowmvi.modules.subscribersModule +import pro.respawn.flowmvi.plugins.compositePlugin internal class StoreImpl( private val config: StoreConfiguration, ) : Store, Provider, RecoverModule, - StorePlugin by pluginModule(config.plugins), + StorePlugin by compositePlugin(config.plugins), SubscribersModule by subscribersModule(), StateModule by stateModule(config.initial), IntentModule by intentModule(config.parallelIntents, config.intentCapacity, config.onOverflow), @@ -60,7 +61,7 @@ internal class StoreImpl( checkNotNull(launchJob.getAndSet(null)) { "Store is closed but was not started" } onStop(it) } - ) pipeline@{ + ) { check(launchJob.getAndSet(coroutineContext.job) == null) { "Store is already started" } launch intents@{ coroutineScope { @@ -83,6 +84,7 @@ internal class StoreImpl( override suspend fun PipelineContext.recover(e: Exception) { withContext(this@StoreImpl) { + if (e is UnrecoverableException) throw e onException(e)?.let { throw UnhandledStoreException(it) } } } diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/CachePluginTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/CachePluginTest.kt new file mode 100644 index 00000000..8ed5e3f3 --- /dev/null +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/CachePluginTest.kt @@ -0,0 +1,43 @@ +package pro.respawn.flowmvi.test.plugin + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import pro.respawn.flowmvi.plugins.cachePlugin +import pro.respawn.flowmvi.plugins.cached +import pro.respawn.flowmvi.util.TestAction +import pro.respawn.flowmvi.util.TestIntent +import pro.respawn.flowmvi.util.TestState + +class CachePluginTest : FreeSpec({ + "Given cache plugin" - { + var inits = 0 + val value = cached<_, TestState, TestIntent, TestAction> { inits++ } + val plugin = cachePlugin(value) + plugin.test(TestState.Some) { + "and onStart invoked" - { + onStart() + "then should be inited" { + inits shouldBe 1 + } + } + "and onStop invoked" - { + onStop(null) + "then should not init again" { + inits shouldBe 1 + } + "and should throw on access" { + shouldThrow { + println(value.value) + } + } + } + "and onStart invoked again" - { + onStart() + "then should init again" { + inits shouldBe 2 + } + } + } + } +}) diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/InitPluginsTests.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/InitPluginsTests.kt new file mode 100644 index 00000000..8be7fce9 --- /dev/null +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/InitPluginsTests.kt @@ -0,0 +1,51 @@ +package pro.respawn.flowmvi.test.plugin + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import pro.respawn.flowmvi.plugins.disallowRestartPlugin +import pro.respawn.flowmvi.plugins.initPlugin +import pro.respawn.flowmvi.util.TestAction +import pro.respawn.flowmvi.util.TestIntent +import pro.respawn.flowmvi.util.TestState +import kotlin.IllegalStateException + +class InitPluginsTests : FreeSpec({ + "Given disallow restart plugin" - { + disallowRestartPlugin().test(TestState.Some) { + "and store is started" - { + onStart() + "then cannot be started again" { + timeTravel.starts shouldBe 1 + shouldThrow { onStart() } + } + "and store is stopped" - { + onStop(null) + timeTravel.stops shouldBe 1 + "then cannot be restarted still" { + shouldThrow { onStart() } + } + } + } + } + } + "Given init plugin" - { + var inits = 0 + initPlugin { inits++ }.test(TestState.Some) { + "and store is started" - { + onStart() + timeTravel.starts shouldBe 1 + "then init should be invoked" { + inits shouldBe 1 + } + } + "and store is started again" - { + onStart() + "then init should be invoked" { + timeTravel.starts shouldBe 2 + inits shouldBe 2 + } + } + } + } +}) diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/RecoverPluginTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/RecoverPluginTest.kt new file mode 100644 index 00000000..750edb77 --- /dev/null +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/RecoverPluginTest.kt @@ -0,0 +1,30 @@ +package pro.respawn.flowmvi.test.plugin + +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import pro.respawn.flowmvi.plugins.recoverPlugin +import pro.respawn.flowmvi.util.TestAction +import pro.respawn.flowmvi.util.TestIntent +import pro.respawn.flowmvi.util.TestState + +class RecoverPluginTest : FreeSpec({ + "Given a recover plugin that swallows exceptions" - { + var recovers = 0 + recoverPlugin { + ++recovers + null + }.test(TestState.Some) { + "And an exception is thrown" - { + val exception = RuntimeException() + val result = onException(exception) + "then exception should be handled" { + timeTravel.exceptions.shouldContainExactly(exception) + result.shouldBeNull() + recovers shouldBe 1 + } + } + } + } +}) diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/ReducePluginTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/ReducePluginTest.kt new file mode 100644 index 00000000..faab2ee2 --- /dev/null +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/ReducePluginTest.kt @@ -0,0 +1,34 @@ +package pro.respawn.flowmvi.test.plugin + +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import pro.respawn.flowmvi.plugins.consumeIntentsPlugin +import pro.respawn.flowmvi.plugins.reducePlugin +import pro.respawn.flowmvi.util.TestAction +import pro.respawn.flowmvi.util.TestIntent +import pro.respawn.flowmvi.util.TestState + +class ReducePluginTest : FreeSpec({ + "Given consume intents plugin" - { + val plugin = consumeIntentsPlugin() + plugin.test(TestState.Some) { + "then intent is consumed" - { + onIntent(TestIntent { }).shouldBeNull() + } + } + } + "Given reduce plugin that consumes intents" - { + var invocations = 0 + val plugin = reducePlugin { ++invocations } + plugin.test(TestState.Some) { + "then reduce should invoke action on intent" - { + val result = onIntent(TestIntent { }) + invocations shouldBe 1 + "and intent is consumed" { + result.shouldBeNull() + } + } + } + } +}) diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/StorePluginTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/StorePluginTest.kt index c3023f89..758ebfa9 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/StorePluginTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/StorePluginTest.kt @@ -17,19 +17,16 @@ import pro.respawn.flowmvi.util.asUnconfined import pro.respawn.flowmvi.util.idle import pro.respawn.flowmvi.util.testStore +// TODO: +// parent store plugin +// while subscribed plugin: job cancelled, multiple subs, single sub +// subscriber manager +// subscriber count is correct +// subscriber count decrements correctly +// await subscribers +// job manager class StorePluginTest : FreeSpec({ asUnconfined() - // TODO: - // action: emit, action() - // intent: emit, action() - // all store plugin events are invoked - // subscriber count is correct - // subscriber count decrements correctly - // saved state plugin - // disallow restart plugin - // parent store plugin - // cache plugin - // while subscribed plugin: job cancelled, multiple subs, single sub "given test store" - { "and recover plugin that throws".config( diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/UndoRedoPluginTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/UndoRedoPluginTest.kt index 1d03a131..a82cea44 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/UndoRedoPluginTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/UndoRedoPluginTest.kt @@ -1,106 +1,116 @@ package pro.respawn.flowmvi.test.plugin -import app.cash.turbine.test import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldThrowAny import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.shouldBe -import pro.respawn.flowmvi.dsl.updateState +import pro.respawn.flowmvi.plugins.UndoRedo import pro.respawn.flowmvi.plugins.undoRedoPlugin -import pro.respawn.flowmvi.test.subscribeAndTest import pro.respawn.flowmvi.util.TestAction import pro.respawn.flowmvi.util.TestIntent import pro.respawn.flowmvi.util.TestState -import pro.respawn.flowmvi.util.TestState.SomeData import pro.respawn.flowmvi.util.asUnconfined import pro.respawn.flowmvi.util.idle -import pro.respawn.flowmvi.util.testStore -import pro.respawn.flowmvi.util.testTimeTravelPlugin + +private fun UndoRedo.shouldBeEmpty() { + queueSize shouldBe 0 + canUndo shouldBe false + canRedo shouldBe false + index.value shouldBe -1 +} class UndoRedoPluginTest : FreeSpec({ asUnconfined() - val timeTravel = testTimeTravelPlugin() - beforeEach { - timeTravel.reset() - } - "Given undo/redo plugin" - { - val plugin = undoRedoPlugin(10) - "and an intent that changes state" - { - val intent = TestIntent { - plugin( - redo = { - updateState, _> { - copy(data = data + 1) - } - }, - ) { - updateState, _> { - copy(data = data - 1) - } + "Given undo/redo" - { + val plugin = UndoRedo(10) + var counter = 0 + suspend fun run() = plugin.invoke(true, redo = { ++counter }, undo = { --counter }) + "and when queue is empty" - { + "then undo throws" { + shouldThrowAny { + plugin.undo(true) } } - - "and when intent is sent" - { - "then it is executed" { - testStore(initial = SomeData(0), timeTravel = timeTravel) { - install(plugin) - }.subscribeAndTest { - intent(intent) - idle() - state shouldBe SomeData(1) - idle() - plugin.isQueueEmpty shouldBe false - } + } + "and when redo is invoked" - { + "then block is executed" { + with(plugin) { + run() + counter shouldBe 1 + isQueueEmpty shouldBe false + index.value shouldBe 0 + canRedo shouldBe false + canUndo shouldBe true } } + } - "and when undo is called" - { - "then state is restored" { - testStore(initial = SomeData(0), timeTravel = timeTravel) { - install(plugin) - }.subscribeAndTest { - idle() - intent(intent) - idle() - plugin.undo(require = true) - idle() - plugin.index.value shouldBe -1 - idle() - states.test { - awaitItem() shouldBe SomeData(0) - } - } + "and when undo is called" - { + "then state is restored" { + with(plugin) { + plugin.undo(true) + counter shouldBe 0 + isQueueEmpty shouldBe false + canRedo shouldBe true + canUndo shouldBe false + index.value shouldBe -1 } } - "and multiple intents are sent" - { - testStore(initial = SomeData(0), timeTravel = timeTravel) { - install(plugin) - }.subscribeAndTest { - val reps = 5 - repeat(5) { - intent(intent) + } + "and when multiple operations are executed" - { + plugin.reset() + counter = 0 + val reps = 5 + repeat(5) { run() } + "then queue size matches intent count" { + plugin.index.value shouldBe reps - 1 + counter shouldBe 5 + } + "then multiple actions can be undone" { + plugin.undo(true) + plugin.undo(true) + idle() + plugin.index.value shouldBe reps - 1 - 2 // 2 + counter shouldBe 3 + plugin.queueSize shouldBe 5 + } + "then making another action replaces the redo queue" { + run() + idle() + assertSoftly { + counter shouldBe 4 + plugin.queueSize shouldBe 5 - 2 + 1 + plugin.index.value shouldBe 4 - 1 + } + } + "then undone action can be redone" { + plugin.undo(false) + counter shouldBe 3 + plugin.redo(true) + counter shouldBe 4 + } + } + "and undo/redo is installed as a plugin" - { + undoRedoPlugin(plugin, resetOnException = true).test(TestState.Some) { + "and an exception is thrown" - { + val e = IllegalArgumentException() + onException(e) + "then queue is cleared" { + timeTravel.exceptions shouldContain e + counter shouldBe 4 + plugin.shouldBeEmpty() } - idle() - // "then queue size matches intent count" - plugin.index.value shouldBe reps - 1 - state shouldBe SomeData(5) - // "then multiple actions can be undone" - plugin.undo(true) - plugin.undo(true) - idle() - plugin.index.value shouldBe reps - 1 - 2 // 2 - plugin.queueSize shouldBe 5 - // "then making another action replaces the redo queue" - intent(intent) - idle() - assertSoftly { - plugin.queueSize shouldBe 4 - plugin.index.value shouldBe 3 + } + "and store is closed" - { + run() + counter shouldBe 5 + onStop(null) + "then queue is cleared" { + plugin.shouldBeEmpty() } } } } - "then plugin resets after store is stopped" { - plugin.isQueueEmpty shouldBe true - } } }) diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/ActionShareBehaviorTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/ActionShareBehaviorTest.kt index a05d9553..7a771e57 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/ActionShareBehaviorTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/ActionShareBehaviorTest.kt @@ -12,25 +12,23 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.joinAll import pro.respawn.flowmvi.api.ActionShareBehavior import pro.respawn.flowmvi.dsl.intent -import pro.respawn.flowmvi.plugins.timeTravelPlugin import pro.respawn.flowmvi.test.subscribeAndTest import pro.respawn.flowmvi.test.test import pro.respawn.flowmvi.util.TestAction import pro.respawn.flowmvi.util.TestIntent -import pro.respawn.flowmvi.util.TestState import pro.respawn.flowmvi.util.asUnconfined import pro.respawn.flowmvi.util.idle import pro.respawn.flowmvi.util.testStore +import pro.respawn.flowmvi.util.testTimeTravel class ActionShareBehaviorTest : FreeSpec({ asUnconfined() - val plugin = timeTravelPlugin() - beforeEach { - plugin.reset() - } + val timeTravel = testTimeTravel() + beforeEach { timeTravel.reset() } + "Given store" - { "and actions disabled" - { - val store = testStore(plugin) { + val store = testStore(timeTravel) { actionShareBehavior = ActionShareBehavior.Disabled } "then trying to collect actions throws" { @@ -55,7 +53,7 @@ class ActionShareBehaviorTest : FreeSpec({ } } "and actions are shared" - { - val store = testStore(plugin) { + val store = testStore(timeTravel) { actionShareBehavior = ActionShareBehavior.Share() } @@ -75,13 +73,13 @@ class ActionShareBehaviorTest : FreeSpec({ val intent = TestIntent { action(TestAction.Some) } intent(intent) joinAll(job1, job2) - plugin.intents shouldContain intent - plugin.actions shouldContain TestAction.Some + timeTravel.intents shouldContain intent + timeTravel.actions shouldContain TestAction.Some } } } "and actions are distributed" - { - val store = testStore(plugin) { + val store = testStore(timeTravel) { actionShareBehavior = ActionShareBehavior.Distribute() } "then one subscriber gets the action only" { @@ -103,7 +101,7 @@ class ActionShareBehaviorTest : FreeSpec({ } } "and actions are consumed" - { - val store = testStore(plugin) { + val store = testStore(timeTravel) { actionShareBehavior = ActionShareBehavior.Restrict() } "then one subscriber gets the action only" { diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreComparisonTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreComparisonTest.kt new file mode 100644 index 00000000..363bf767 --- /dev/null +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreComparisonTest.kt @@ -0,0 +1,42 @@ +@file:Suppress("UnnecessaryVariable") + +package pro.respawn.flowmvi.test.store + +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.equals.shouldNotBeEqual +import pro.respawn.flowmvi.util.testStore + +class StoreComparisonTest : FreeSpec({ + "Given two stores without names" - { + val a = testStore { name = null } + val b = testStore { name = null } + "then stores are distinct" { + a shouldNotBeEqual b + } + } + "given two stores with different names" - { + val a = testStore { name = "a" } + val b = testStore { name = "b" } + "then stores are distinct" { + a shouldNotBeEqual b + } + } + "given same store without a name" - { + val a = testStore { name = null } + val b = a + "then store is equal to itself" { + a shouldBeEqual b + } + } + "given different stores with the same name" - { + val a = testStore { name = "a" } + val b = testStore { name = "a" } + "then stores are equal" { + a shouldBeEqual b + } + "then stores are equal to themselves" { + a shouldBeEqual a + } + } +}) diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreContextTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreContextTest.kt index 8cc3e922..2dfaa5cd 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreContextTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreContextTest.kt @@ -14,12 +14,12 @@ import pro.respawn.flowmvi.util.TestIntent import pro.respawn.flowmvi.util.TestState import pro.respawn.flowmvi.util.asUnconfined import pro.respawn.flowmvi.util.testStore -import pro.respawn.flowmvi.util.testTimeTravelPlugin +import pro.respawn.flowmvi.util.testTimeTravel @OptIn(DelicateStoreApi::class) class StoreContextTest : FreeSpec({ asUnconfined() - val plugin = testTimeTravelPlugin() + val plugin = testTimeTravel() beforeEach { plugin.reset() diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreEventsTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreEventsTest.kt index f3f4fc96..f3c12b12 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreEventsTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreEventsTest.kt @@ -1,5 +1,3 @@ -@file:OptIn(DelicateStoreApi::class) - package pro.respawn.flowmvi.test.store import app.cash.turbine.test @@ -10,27 +8,25 @@ import pro.respawn.flowmvi.api.DelicateStoreApi import pro.respawn.flowmvi.dsl.intent import pro.respawn.flowmvi.plugins.recover import pro.respawn.flowmvi.plugins.reduce -import pro.respawn.flowmvi.plugins.timeTravelPlugin import pro.respawn.flowmvi.test.subscribeAndTest import pro.respawn.flowmvi.util.TestAction -import pro.respawn.flowmvi.util.TestIntent import pro.respawn.flowmvi.util.TestState import pro.respawn.flowmvi.util.asUnconfined import pro.respawn.flowmvi.util.testStore +import pro.respawn.flowmvi.util.testTimeTravel +@OptIn(DelicateStoreApi::class) class StoreEventsTest : FreeSpec({ asUnconfined() - val plugin = timeTravelPlugin() - beforeEach { - plugin.reset() - } + val plugin = testTimeTravel() + beforeEach { plugin.reset() } "Given test store" - { "and reducer that sends actions" - { val store = testStore(plugin) "then intents result in actions" { store.subscribeAndTest { - intent { action(TestAction.Some) } + intent { send(TestAction.Some) } // use async api actions.test { awaitItem() shouldBe TestAction.Some } @@ -39,7 +35,9 @@ class StoreEventsTest : FreeSpec({ } "and reducer that changes states" - { val newState = TestState.SomeData(1) - val store = testStore(plugin) + val store = testStore(plugin) { + parallelIntents = true // smoke-test parallel intents as well + } "then intents result in state change" { store.subscribeAndTest { states.test { diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreExceptionsText.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreExceptionsTest.kt similarity index 96% rename from core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreExceptionsText.kt rename to core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreExceptionsTest.kt index c2524f59..46d982b7 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreExceptionsText.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreExceptionsTest.kt @@ -19,15 +19,13 @@ import pro.respawn.flowmvi.test.test import pro.respawn.flowmvi.util.asUnconfined import pro.respawn.flowmvi.util.idle import pro.respawn.flowmvi.util.testStore -import pro.respawn.flowmvi.util.testTimeTravelPlugin +import pro.respawn.flowmvi.util.testTimeTravel -class StoreExceptionsText : FreeSpec({ +class StoreExceptionsTest : FreeSpec({ asUnconfined() - val plugin = testTimeTravelPlugin() - afterEach { - plugin.reset() - } + val plugin = testTimeTravel() + afterEach { plugin.reset() } "Given an exception" - { val e = IllegalArgumentException("Test") diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt index 41589275..0ebb626f 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt @@ -1,6 +1,5 @@ package pro.respawn.flowmvi.test.store -import app.cash.turbine.test import io.kotest.assertions.throwables.shouldNotThrowAny import io.kotest.assertions.throwables.shouldThrowExactly import io.kotest.core.spec.style.FreeSpec @@ -20,16 +19,14 @@ import pro.respawn.flowmvi.util.TestIntent import pro.respawn.flowmvi.util.asUnconfined import pro.respawn.flowmvi.util.idle import pro.respawn.flowmvi.util.testStore -import pro.respawn.flowmvi.util.testTimeTravelPlugin +import pro.respawn.flowmvi.util.testTimeTravel class StoreLaunchTest : FreeSpec({ asUnconfined() - val plugin = testTimeTravelPlugin() - afterEach { - plugin.reset() - } + val timeTravel = testTimeTravel() + afterEach { timeTravel.reset() } "Given store" - { - val store = testStore(plugin) + val store = testStore(timeTravel) "then can be launched and stopped" { coroutineScope { val job = shouldNotThrowAny { @@ -65,7 +62,7 @@ class StoreLaunchTest : FreeSpec({ store.subscribeAndTest { intent { } idle() - plugin.intents.shouldBeSingleton() + timeTravel.intents.shouldBeSingleton() } } } diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt index 57b8b489..cde1a7b1 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt @@ -5,28 +5,24 @@ import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch -import pro.respawn.flowmvi.api.DelicateStoreApi import pro.respawn.flowmvi.dsl.LambdaIntent import pro.respawn.flowmvi.dsl.intent import pro.respawn.flowmvi.dsl.send -import pro.respawn.flowmvi.plugins.timeTravelPlugin import pro.respawn.flowmvi.test.subscribeAndTest import pro.respawn.flowmvi.util.TestAction import pro.respawn.flowmvi.util.TestState import pro.respawn.flowmvi.util.asUnconfined import pro.respawn.flowmvi.util.idle import pro.respawn.flowmvi.util.testStore +import pro.respawn.flowmvi.util.testTimeTravel -@OptIn(DelicateStoreApi::class) class StoreStatesTest : FreeSpec({ asUnconfined() - val plugin = timeTravelPlugin, TestAction>() - beforeEach { - plugin.reset() - } + val timeTravel = testTimeTravel() + beforeEach { timeTravel.reset() } "given lambdaIntent store" - { - val store = testStore(plugin) + val store = testStore(timeTravel) "and intent that blocks state" - { val blockingIntent = LambdaIntent { launch { @@ -37,7 +33,7 @@ class StoreStatesTest : FreeSpec({ } "then state is never updated by another intent" { store.subscribeAndTest { - send(blockingIntent) + emit(blockingIntent) intent { updateState { TestState.SomeData(1) diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt index 5a8e0a02..7f1a0b6a 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt @@ -7,14 +7,16 @@ import pro.respawn.flowmvi.dsl.BuildStore import pro.respawn.flowmvi.dsl.LambdaIntent import pro.respawn.flowmvi.dsl.reduceLambdas import pro.respawn.flowmvi.dsl.store -import pro.respawn.flowmvi.plugins.TimeTravelPlugin +import pro.respawn.flowmvi.plugins.TimeTravel import pro.respawn.flowmvi.plugins.platformLoggingPlugin -import pro.respawn.flowmvi.plugins.timeTravelPlugin +import pro.respawn.flowmvi.plugins.timeTravel -internal fun testTimeTravelPlugin() = timeTravelPlugin, TestAction>() +internal typealias TestTimeTravel = TimeTravel, TestAction> + +internal fun testTimeTravel() = TestTimeTravel() internal fun testStore( - timeTravel: TimeTravelPlugin, TestAction> = timeTravelPlugin(), + timeTravel: TestTimeTravel = testTimeTravel(), initial: TestState = TestState.Some, behavior: ActionShareBehavior = ActionShareBehavior.Distribute(), configure: BuildStore, TestAction> = {}, @@ -22,7 +24,7 @@ internal fun testStore( debuggable = false name = "TestStore" actionShareBehavior = behavior - install(timeTravel) + timeTravel(timeTravel) install(platformLoggingPlugin(name)) configure() reduceLambdas() diff --git a/docs/android.md b/docs/android.md index af9bf972..12235bf6 100644 --- a/docs/android.md +++ b/docs/android.md @@ -17,7 +17,7 @@ This example is also fully implemented in the sample app. class CounterViewModel( repo: CounterRepository, handle: SavedStateHandle, -) : ViewModel(), ImmutableContainer, CounterAction> { +) : ViewModel(), ImmutableContainer { // the store is lazy here, which is good for performance if you use other properties of the VM. // if you don't want a lazy store, use the regular store() function here @@ -25,7 +25,6 @@ class CounterViewModel( initial = Loading, scope = viewModelScope, ) { - // perks of direct approach debuggable = BuildConfig.DEBUG install(androidLoggingPlugin()) parcelizeState(handle) @@ -73,21 +72,18 @@ From here, the only things left are to inject your `Container` into an instance specify your qualifiers. The most basic setup for Koin will look like this: ```kotlin +// declare inline fun > Module.storeViewModel() { viewModel(qualifier()) { params -> StoreViewModel(get { params }) } } +// inject @Composable inline fun , S : MVIState, I : MVIIntent, A : MVIAction> storeViewModel( - viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { - "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" - }, - key: String? = null, - extras: CreationExtras = defaultExtras(viewModelStoreOwner), - scope: Scope = getKoinScope(), noinline parameters: ParametersDefinition? = null, -): StoreViewModel = getViewModel(qualifier(), viewModelStoreOwner, key, extras, scope, parameters) +): StoreViewModel = getViewModel(qualifier = qualifier(), parameters = parameters) +// use val appModule = module { singleOf(::CounterRepository) factoryOf(::CounterContainer) @@ -101,13 +97,11 @@ e.g. `StoreViewModel`, or it will be DI framework will fail, likely in runtime. This is a more robust and multiplatform friendly approach that is slightly more boilerplatish but does not require you -to subclass ViewModels. -The biggest downside of this approach is that you'll have to use qualifiers with your DI framework to distinguish -between different ViewModels. This example is also demonstrated in the sample app. +to subclass ViewModels. This example is also demonstrated in the sample app. ## UI Layer -It doesn't matter which UI framework you use. Neither your Contract or your Container/ViewModel will change in any way. +It doesn't matter which UI framework you use. Neither your Contract nor your `Container` will change in any way. ### Compose @@ -115,7 +109,7 @@ It doesn't matter which UI framework you use. Neither your Contract or your Cont It is discouraged to use Lambda intents with Compose as that will not only leak the context of the store but also degrade performance. -You don't have to annotate your state with `@Immutable` as `MVIState` is already marked immutable. +?> You don't have to annotate your state with `@Immutable` as `MVIState` is already marked immutable. ```kotlin @Composable diff --git a/docs/index.html b/docs/index.html index 7b46c837..f91a4a88 100644 --- a/docs/index.html +++ b/docs/index.html @@ -5,7 +5,7 @@ FlowMVI - + @@ -99,7 +99,6 @@ navigator.serviceWorker.register('sw.js') } - diff --git a/docs/plugins.md b/docs/plugins.md index 1f72b078..390d7853 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -6,7 +6,6 @@ All stores are mostly based on plugins, and their behavior is entirely determine Plugins can influence subscription, stopping, and all other forms of store behavior. Access the store's context and other functions through the `PipelineContext` receiver. It is not recommended to implement the `StorePlugin` interface, -if you really need to subclass something, extend `AbstractStorePlugin` instead. If you do override that interface, you **must** comply with the hashcode/equals contract of the plugin system, described below. @@ -20,9 +19,7 @@ val plugin = plugin { ``` ---- - -Here are all the dsl functions of a plugin: +## Plugin DSL ### Name diff --git a/docs/quickstart.md b/docs/quickstart.md index 1178f467..5499054d 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -35,6 +35,37 @@ dependencies { } ``` +The library's minimum JVM target is set to 11 (sadly still not the default). +If you encounter an error: + +``` +Cannot inline bytecode built with JVM target 11 into bytecode that +is being built with JVM target 1.8. Please specify proper '-jvm-target' option +``` + +Then configure your kotlin compilation to target JVM 11 in your root `build.gradle.kts`: + +```kotlin +allprojects.tasks.withType().configureEach { + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + } +} +``` + +And in your module-level gradle files, set: +```kotlin +android { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} +``` + +If you support Android API <26, you will also need to +enable [desugaring](https://developer.android.com/studio/write/java8-support). + ## Step 2: Choose your style FlowMVI supports both MVI (strict model-driven logic) and the MVVM+ (functional, lambda-driven logic) styles. @@ -66,7 +97,7 @@ intents. This will prevent leaking the context of the store to subscribers. ## Step 3: Describe your Contract
-Click for general advice on how to define a contract if you're a newbie +Click for general advice on how to define a contract Describing the contract first makes building the logic easier because you have everything you need at the start. To define your contract, ask yourself the following: @@ -121,14 +152,13 @@ sealed interface CounterIntent : MVIIntent { // MVVM+ Style Intents typealias CounterIntent = LambdaIntent -// Optional - can be disabled by using Nothing as a type sealed interface CounterAction : MVIAction { data class ShowMessage(val message: String) : CounterAction } ``` * If your store does not have a `State`, you can use an `EmptyState` object. -* +* If your store does not have side-effects, use `Nothing` in place of the side-effect type. ## Step 4: Define your store @@ -198,7 +228,7 @@ Prebuilt plugins come with a nice dsl when building a store. Here's the list of with `whileSubscribed { }`. * **Logging Plugin** - log events to a log stream of the target platform. Install with `platformLoggingPlugin()` * **Saved State Plugin** - Save state somewhere else when it changes, and restore when the store starts. Android has - `parcelizeState` and `serializeState` plugins based on this one. Install with `saveState(get = {}, set = {})`. + `parcelizeState` and `serializeState` plugins based on this one. See [saved state](./savedstate.md) for details. * **Job Manager Plugin** - keep track of long-running tasks, cancel and schedule them. Install with `manageJobs()`. * **Await Subscribers Plugin** - let the store wait for a specified number of subscribers to appear before starting its work. Install with `awaitSubscribers()`. @@ -274,11 +304,6 @@ So make sure to consider how your plugins affect the store's logic when using an The discussion above warrants another note. -!> Because plugins are optional, you can do weird things with them. The library has validations in place to make sure -you handle intents, but it's possible to create a store like this: -`val store = store(Loading) { }`. -This is a store that does **literally nothing**. If you forget to install a plugin, it will never be run. - ### Step 6: Create, inject and provide dependencies You'll likely want to provide some dependencies for the store to use and to create additional functions instead of just @@ -311,6 +336,8 @@ Next steps: * Learn how to create custom [plugins](plugins.md) * Learn how to use DI and [Android-specific features](android.md) +* Learn how to [persist and restore state](savedstate.md) +* Get answers to common [questions](faq.md) * [Read an article](https://medium.com/@Nek.12/success-story-how-flowmvi-has-changed-the-fate-of-our-project-3c1226890d67) about how our team has used the library to improve performance and stability of our app, with practical examples. * Explore diff --git a/docs/roadmap.md b/docs/roadmap.md index a83b4810..8666d1ad 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,15 +1,17 @@ ## Roadmap for v2.x: - [x] ~~Docs for DI~~ -- [ ] Add tests for plugins +- [ ] Docs for how to implement analytics +- [ ] Docs on how to manage states +- [ ] Docs on reusing and injecting store configurations +- [x] ~~Add tests for plugins~~ - [x] ~~Job manager plugin that can be accessed to cancel / start long-running jobs~~ - [x] ~~Undo/Redo plugin~~ - [x] ~~Implement params support for DI~~ - [x] ~~Implement a better native logging with expect/actual~~ -- [ ] Composable store configurations, reusing some part of the store configuration +- [x] ~~Composable store configurations, reusing some part of the store configuration~~ - [x] ~~DSL for composing stores to seamlessly split responsibilities~~ - [x] ~~More debug checks and optimizations~~ -- [ ] Consider adding an Analytics plugin - [x] ~~Compose Multiplatform support~~ - [ ] Better iOS support - [ ] Better JS support diff --git a/gradle.properties b/gradle.properties index 9e7d2b44..716f0fbc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -24,3 +24,8 @@ android.nonFinalResIds=true kotlin.native.ignoreIncorrectDependencies=true kotlinx.atomicfu.enableJvmIrTransformation=true org.jetbrains.compose.experimental.macos.enabled=true +kotlin.experimental.tryK2=false +android.lint.useK2Uast=true +ksp.useKSP2=false +nl.littlerobots.vcu.resolver=true +org.gradle.console=rich diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 98fcdf28..20569cdb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,17 @@ [versions] activity = "1.8.2" -compose = "1.5.4" -compose-plugin = "1.5.11" -compose-compiler = "1.5.7" +compose = "1.6.1" +compose-compiler = "1.5.9" +compose-plugin = "1.5.12" composeDetektPlugin = "1.3.0" core-ktx = "1.12.0" -coroutines = "1.7.3" -dependencyAnalysisPlugin = "1.28.0" -detekt = "1.23.4" -detektFormattingPlugin = "1.23.4" +coroutines = "1.8.0-RC2" +dependencyAnalysisPlugin = "1.29.0" +detekt = "1.23.5" +detektFormattingPlugin = "1.23.5" dokka = "1.9.10" fragment = "1.6.2" -gradleAndroid = "8.3.0-beta01" +gradleAndroid = "8.3.0-rc01" gradleDoctorPlugin = "0.9.1" junit = "4.13.2" koin = "3.5.3" @@ -19,15 +19,14 @@ koin-compose = "1.1.2" kotest = "5.8.0" kotest-plugin = "5.8.0" # @pin -kotlin = "1.9.21" -kotlinx-atomicfu = "0.23.1" -lifecycle = "2.6.2" +kotlin = "1.9.22" +kotlin-io = "0.3.1" +kotlinx-atomicfu = "0.23.2" +lifecycle = "2.7.0" material = "1.11.0" -turbine = "1.0.0" -versionCatalogUpdatePlugin = "0.8.3" -versionsPlugin = "0.50.0" -kotlin-io = "0.3.0" serialization = "1.6.2" +turbine = "1.0.0" +versionCatalogUpdatePlugin = "0.8.4" [libraries] android-gradle = { module = "com.android.tools.build:gradle", version.ref = "gradleAndroid" } @@ -47,7 +46,6 @@ detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", detekt-gradle = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } detekt-libraries = { module = "io.gitlab.arturbosch.detekt:detekt-rules-libraries", version.ref = "detekt" } dokka-android = { module = "org.jetbrains.dokka:android-documentation-plugin", version.ref = "dokka" } -gradle-versions = { module = "com.github.ben-manes:gradle-versions-plugin", version.ref = "versionsPlugin" } junit = { module = "junit:junit", version.ref = "junit" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-android-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } @@ -63,17 +61,16 @@ kotlin-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } -kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } -kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-common", version.ref = "kotlin" } kotlin-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlin-io" } -kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" } +kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-common", version.ref = "kotlin" } lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } lifecycle-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "lifecycle" } lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } material = { module = "com.google.android.material:material", version.ref = "material" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } -version-gradle = { module = "com.github.ben-manes:gradle-versions-plugin", version.ref = "versionsPlugin" } [bundles] koin = [ @@ -102,6 +99,5 @@ gradleDoctor = { id = "com.osacky.doctor", version.ref = "gradleDoctorPlugin" } jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } kotest = { id = "io.kotest.multiplatform", version.ref = "kotest-plugin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "versionCatalogUpdatePlugin" } -versions = { id = "com.github.ben-manes.versions", version.ref = "versionsPlugin" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "versionCatalogUpdatePlugin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1af9e093..a80b22ce 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt index c2536320..d61dc2b8 100644 --- a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt +++ b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt @@ -6,7 +6,7 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import pro.respawn.flowmvi.savedstate.api.Saver import pro.respawn.flowmvi.savedstate.api.ThrowRecover -import pro.respawn.flowmvi.savedstate.plugins.nameByType +import pro.respawn.flowmvi.savedstate.util.nameByType /** * A [Saver] implementation that saves the specified value of [T] to a [handle]. diff --git a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt index 4aa46a4b..1cb436a8 100644 --- a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt +++ b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt @@ -14,6 +14,8 @@ import pro.respawn.flowmvi.savedstate.api.ThrowRecover import pro.respawn.flowmvi.savedstate.dsl.MapSaver import pro.respawn.flowmvi.savedstate.dsl.ParcelableSaver import pro.respawn.flowmvi.savedstate.dsl.TypedSaver +import pro.respawn.flowmvi.savedstate.util.PluginNameSuffix +import pro.respawn.flowmvi.savedstate.util.nameByType import kotlin.coroutines.CoroutineContext /** diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt index 4f4e380b..f39d8da2 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt @@ -17,6 +17,7 @@ import pro.respawn.flowmvi.savedstate.api.SaveBehavior import pro.respawn.flowmvi.savedstate.api.SaveBehavior.OnChange import pro.respawn.flowmvi.savedstate.api.SaveBehavior.OnUnsubscribe import pro.respawn.flowmvi.savedstate.api.Saver +import pro.respawn.flowmvi.savedstate.dsl.CallbackSaver import pro.respawn.flowmvi.savedstate.dsl.CompressedFileSaver import pro.respawn.flowmvi.savedstate.dsl.DefaultFileSaver import pro.respawn.flowmvi.savedstate.dsl.FileSaver @@ -24,37 +25,13 @@ import pro.respawn.flowmvi.savedstate.dsl.JsonSaver import pro.respawn.flowmvi.savedstate.dsl.MapSaver import pro.respawn.flowmvi.savedstate.dsl.NoOpSaver import pro.respawn.flowmvi.savedstate.dsl.TypedSaver +import pro.respawn.flowmvi.savedstate.util.EmptyBehaviorsMessage +import pro.respawn.flowmvi.savedstate.util.PluginNameSuffix +import pro.respawn.flowmvi.savedstate.util.nameByType +import pro.respawn.flowmvi.savedstate.util.restoreCatching +import pro.respawn.flowmvi.savedstate.util.saveCatching import kotlin.coroutines.CoroutineContext -/** - * Get the name of the class, removing the "State" suffix, if present - */ -public inline fun nameByType(): String? = T::class.simpleName?.removeSuffix("State") - -@PublishedApi -internal val PluginNameSuffix: String = "SaveStatePlugin" - -@PublishedApi -internal const val EmptyBehaviorsMessage: String = """ -You wanted to save the state but have not provided any behaviors. -Please supply at least one behavior or remove the plugin as it would do nothing otherwise. -""" - -@PublishedApi -internal suspend fun Saver.saveCatching(state: S?): Unit = try { - save(state) -} catch (expected: Exception) { - recover(expected) - Unit -} - -@PublishedApi -internal suspend fun Saver.restoreCatching(): S? = try { - restore() -} catch (expected: Exception) { - recover(expected) -} - /** * Creates a plugin for persisting and restoring [MVIState] of the current store. * @@ -65,13 +42,13 @@ internal suspend fun Saver.restoreCatching(): S? = try { * * [JsonSaver] for saving the state as a JSON. * * [FileSaver] for saving the state to a file. See [DefaultFileSaver] for custom file writing logic. * * [CompressedFileSaver] for saving the state to a file and compressing it. + * * [CallbackSaver] for logging. * * [NoOpSaver] for testing. * * The plugin will determine **when** to save the state based on [behaviors]. * Please see [SaveBehavior] documentation for more details. * this function will throw if the [behaviors] are empty. * ---- - * * By default, the plugin will use the name derived from the store's name, or the state [S] class name. * * If [resetOnException] is `true`, the plugin will attempt to clear the state if an exception is thrown. * * All state saving is done in a background coroutine. * * The state **restoration**, however, is done **before** the store starts. @@ -82,11 +59,11 @@ internal suspend fun Saver.restoreCatching(): S? = try { * @see [Saver] */ @FlowMVIDSL -public inline fun saveStatePlugin( +public fun saveStatePlugin( saver: Saver, context: CoroutineContext, + name: String? = null, behaviors: Set = SaveBehavior.Default, - name: String? = "${nameByType().orEmpty()}$PluginNameSuffix", resetOnException: Boolean = true, ): StorePlugin = plugin { require(behaviors.isNotEmpty()) { EmptyBehaviorsMessage } @@ -133,6 +110,8 @@ public inline fun saveState /** * Creates and installs a new [saveStatePlugin]. Please see the parent overload for more info. * + * * By default, the plugin will use the name derived from the store's name, or the state [S] class name. + * * @see saveStatePlugin */ @FlowMVIDSL @@ -142,4 +121,4 @@ public inline fun StoreBuil behaviors: Set = SaveBehavior.Default, name: String? = "${this.name ?: nameByType().orEmpty()}$PluginNameSuffix", resetOnException: Boolean = true, -): Unit = install(saveStatePlugin(saver, context, behaviors, name, resetOnException)) +): Unit = install(saveStatePlugin(saver, context, name, behaviors, resetOnException)) diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt index 54040816..1dc98722 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt @@ -14,6 +14,8 @@ import pro.respawn.flowmvi.savedstate.api.ThrowRecover import pro.respawn.flowmvi.savedstate.dsl.CompressedFileSaver import pro.respawn.flowmvi.savedstate.dsl.JsonSaver import pro.respawn.flowmvi.savedstate.dsl.TypedSaver +import pro.respawn.flowmvi.savedstate.util.PluginNameSuffix +import pro.respawn.flowmvi.savedstate.util.nameByType import kotlin.coroutines.CoroutineContext /** diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/util/Util.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/util/Util.kt new file mode 100644 index 00000000..8e51ea1f --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/util/Util.kt @@ -0,0 +1,32 @@ +package pro.respawn.flowmvi.savedstate.util + +import pro.respawn.flowmvi.savedstate.api.Saver + +/** + * Get the name of the class, removing the "State" suffix, if present + */ +public inline fun nameByType(): String? = T::class.simpleName?.removeSuffix("State") + +@PublishedApi +internal val PluginNameSuffix: String = "SaveStatePlugin" + +@PublishedApi +internal const val EmptyBehaviorsMessage: String = """ +You wanted to save the state but have not provided any behaviors. +Please supply at least one behavior or remove the plugin as it would do nothing otherwise. +""" + +@PublishedApi +internal suspend fun Saver.saveCatching(state: S?): Unit = try { + save(state) +} catch (expected: Exception) { + recover(expected) + Unit +} + +@PublishedApi +internal suspend fun Saver.restoreCatching(): S? = try { + restore() +} catch (expected: Exception) { + recover(expected) +} diff --git a/test/build.gradle.kts b/test/build.gradle.kts index 02d50ae1..086affe5 100644 --- a/test/build.gradle.kts +++ b/test/build.gradle.kts @@ -1,4 +1,3 @@ -@Suppress("DSL_SCOPE_VIOLATION") plugins { id("pro.respawn.shared-library") } @@ -11,4 +10,5 @@ dependencies { commonMainImplementation(kotlin("test")) commonMainApi(libs.kotlin.coroutines.core) commonMainApi(libs.kotlin.coroutines.test) + commonMainApi(libs.kotlin.atomicfu) } diff --git a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/StoreTestScope.kt b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/StoreTestScope.kt new file mode 100644 index 00000000..ee9b464a --- /dev/null +++ b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/StoreTestScope.kt @@ -0,0 +1,61 @@ +package pro.respawn.flowmvi.test + +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withTimeout +import pro.respawn.flowmvi.api.DelicateStoreApi +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.Provider +import pro.respawn.flowmvi.api.Store +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * A class which implements a dsl for testing [Store]. + */ +public class StoreTestScope @PublishedApi internal constructor( + public val provider: Provider, + public val store: Store, + public val timeoutMs: Long = 3000L, +) : Store by store, Provider by provider { + + @OptIn(DelicateStoreApi::class) + override val state: S by store::state + override suspend fun emit(intent: I): Unit = store.emit(intent) + override fun intent(intent: I): Unit = store.intent(intent) + + /** + * Assert that [Provider.state] is equal to [state] + */ + public suspend inline infix fun I.resultsIn(state: S) { + emit(this) + assertEquals(states.value, state, "Expected state to be $state but got ${states.value}") + } + + /** + * Assert that [Provider.state]'s state is of type [S] + */ + public suspend inline fun I.resultsIn() { + emit(this) + assertIs(states.value) + } + + /** + * Assert that intent [this] passes checks defined in [assertion] + */ + public suspend inline infix fun I.resultsIn(assertion: () -> Unit) { + emit(this) + assertion() + } + + /** + * Assert that intent [this] results in [action] + */ + public suspend inline infix fun I.resultsIn(action: A) { + emit(this) + withTimeout(timeoutMs) { + assertEquals(action, actions.firstOrNull()) + } + } +} diff --git a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt index 10e760b8..39229de1 100644 --- a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt +++ b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt @@ -4,67 +4,12 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.withTimeout -import pro.respawn.flowmvi.api.DelicateStoreApi import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState -import pro.respawn.flowmvi.api.Provider import pro.respawn.flowmvi.api.Store -import kotlin.test.assertEquals -import kotlin.test.assertIs - -/** - * A class which implements a dsl for testing [Store]. - */ -public class StoreTestScope @PublishedApi internal constructor( - public val provider: Provider, - public val store: Store, - public val timeoutMs: Long = 3000L, -) : Store by store, Provider by provider { - - @OptIn(DelicateStoreApi::class) - override val state: S by store::state - override suspend fun emit(intent: I): Unit = store.emit(intent) - override fun intent(intent: I): Unit = store.intent(intent) - - /** - * Assert that [Provider.state] is equal to [state] - */ - public suspend inline infix fun I.resultsIn(state: S) { - emit(this) - assertEquals(states.value, state, "Expected state to be $state but got ${states.value}") - } - - /** - * Assert that [Provider.state]'s state is of type [S] - */ - public suspend inline fun I.resultsIn() { - emit(this) - assertIs(states.value) - } - - /** - * Assert that intent [this] passes checks defined in [assertion] - */ - public suspend inline infix fun I.resultsIn(assertion: () -> Unit) { - emit(this) - assertion() - } - - /** - * Assert that intent [this] results in [action] - */ - public suspend inline infix fun I.resultsIn(action: A) { - emit(this) - withTimeout(timeoutMs) { - assertEquals(action, actions.firstOrNull()) - } - } -} /** * Call [Store.start] and then execute [block], cancelling the store afterwards diff --git a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/PluginTestDsl.kt b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/PluginTestDsl.kt new file mode 100644 index 00000000..35aaa4de --- /dev/null +++ b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/PluginTestDsl.kt @@ -0,0 +1,26 @@ +package pro.respawn.flowmvi.test.plugin + +import pro.respawn.flowmvi.api.FlowMVIDSL +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.StorePlugin +import pro.respawn.flowmvi.plugins.TimeTravel +import kotlin.coroutines.coroutineContext + +/** + * A function that runs a test on a [StorePlugin]. + * + * * This function suspends until the test is complete. + * * The plugin may launch new coroutines, which will cause the test to suspend until the test scope is exited. + * * The plugin may produce side effects which are tracked in the [PluginTestScope.timeTravel] property. + * * The plugin may change the state, which is accessible via the [PluginTestScope.state] property. + * * You can use [TestPipelineContext] which is provided with the [PluginTestScope], to set up the plugin's + * environment for the test. + */ +@FlowMVIDSL +public suspend inline fun StorePlugin.test( + initial: S, + timeTravel: TimeTravel = TimeTravel(), + crossinline block: suspend PluginTestScope.() -> Unit, +): Unit = PluginTestScope(initial, coroutineContext, this@test, timeTravel).block() diff --git a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/PluginTestScope.kt b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/PluginTestScope.kt new file mode 100644 index 00000000..6ac3260f --- /dev/null +++ b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/PluginTestScope.kt @@ -0,0 +1,46 @@ +package pro.respawn.flowmvi.test.plugin + +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.PipelineContext +import pro.respawn.flowmvi.api.StorePlugin +import pro.respawn.flowmvi.plugins.TimeTravel +import pro.respawn.flowmvi.plugins.compositePlugin +import pro.respawn.flowmvi.plugins.timeTravelPlugin +import kotlin.coroutines.CoroutineContext + +/** + * A class which provides DSL for testing a [StorePlugin]. + * + * Contains: + * * [ctx] or `this` - a mock pipeline context configured specifically for testing. + * * [TestPipelineContext.plugin] - the plugin being tested + * * [timeTravel] embedded time travel plugin to track any side effects that the plugin produces. + * + * See [StorePlugin.test] for a function that allows to test the plugin + */ +public class PluginTestScope private constructor( + private val ctx: TestPipelineContext, + public val timeTravel: TimeTravel, +) : PipelineContext by ctx, StorePlugin by ctx.plugin { + + public constructor( + initial: S, + coroutineContext: CoroutineContext, + plugin: StorePlugin, + timeTravel: TimeTravel, + ) : this( + timeTravel = timeTravel, + ctx = TestPipelineContext( + initial = initial, + coroutineContext = coroutineContext, + plugin = compositePlugin(setOf(timeTravelPlugin(timeTravel), plugin), plugin.name), + ), + ) + + // compiler bug which crashes compilation because both context and plugin declare equals + override fun equals(other: Any?): Boolean = ctx.plugin == other + override fun hashCode(): Int = ctx.plugin.hashCode() + public val state: S by ctx::state +} diff --git a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt new file mode 100644 index 00000000..516a4b9a --- /dev/null +++ b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt @@ -0,0 +1,47 @@ +package pro.respawn.flowmvi.test.plugin + +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.launch +import pro.respawn.flowmvi.api.DelicateStoreApi +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.PipelineContext +import pro.respawn.flowmvi.api.StorePlugin +import kotlin.coroutines.CoroutineContext + +internal class TestPipelineContext @PublishedApi internal constructor( + initial: S, + override val coroutineContext: CoroutineContext, + val plugin: StorePlugin, +) : PipelineContext { + + var state: S by atomic(initial) + private set + + @DelicateStoreApi + override fun send(action: A) { + launch { with(plugin) { onAction(action) } } + } + + override suspend fun action(action: A) { + with(plugin) { onAction(action) } + } + + override suspend fun emit(intent: I): Unit = with(plugin) { onIntent(intent) } + override fun intent(intent: I) { + launch { emit(intent) } + } + + override suspend fun updateState(transform: suspend S.() -> S) { + with(plugin) { + onState(state, state.transform())?.also { state = it } + } + } + + override suspend fun withState(block: suspend S.() -> Unit): Unit = block(state) + + override fun useState(block: S.() -> S) { + state = block(state) + } +}