From 1e2e6a6da9d702827dea024e1ab4b008d0d5fc66 Mon Sep 17 00:00:00 2001 From: Merajul Arefin Date: Thu, 15 Oct 2026 09:42:20 +0600 Subject: [PATCH 1/6] build: set up unit tests with kotlin.test in every module - Plain Kotlin/JVM modules run JUnit 6 through the JUnit Platform, the setup Kotlin's docs use, which also brings parameterized tests for the YouTube layer's saved responses later. - Android modules run JUnit 4, AGP's default, because Robolectric and Compose's test rules need it and JUnit 6 deprecates the engine that would run JUnit 4 tests. - Both use kotlin.test's annotations and assertions, so a test reads the same in either kind of module. - Add the JUnit Platform launcher explicitly, because Gradle 9 no longer puts it on the test classpath. - Add kotlinx-coroutines-test to both, since most tests will cover coroutine code. --- .../AndroidApplicationConventionPlugin.kt | 1 + .../AndroidLibraryConventionPlugin.kt | 1 + .../buildlogic/JvmLibraryConventionPlugin.kt | 1 + .../github/dewfallapp/buildlogic/Testing.kt | 41 +++++++++++++++++++ gradle/libs.versions.toml | 18 ++++++++ 5 files changed, 62 insertions(+) create mode 100644 build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/Testing.kt diff --git a/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/AndroidApplicationConventionPlugin.kt index dbee610..3cc7c37 100644 --- a/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/AndroidApplicationConventionPlugin.kt @@ -28,6 +28,7 @@ class AndroidApplicationConventionPlugin : Plugin { } configureJavaToolchain() + configureAndroidUnitTests() } } } diff --git a/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/AndroidLibraryConventionPlugin.kt index a39c6a5..6d097e3 100644 --- a/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/AndroidLibraryConventionPlugin.kt @@ -34,6 +34,7 @@ class AndroidLibraryConventionPlugin : Plugin { } configureJavaToolchain() + configureAndroidUnitTests() } } } diff --git a/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/JvmLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/JvmLibraryConventionPlugin.kt index e3cc622..b7c7de7 100644 --- a/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/JvmLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/JvmLibraryConventionPlugin.kt @@ -21,6 +21,7 @@ class JvmLibraryConventionPlugin : Plugin { } configureJavaToolchain() + configureJvmUnitTests() // Lint's general checks. Its API check doesn't run here, so configureAndroidApiCheck // checks these modules against the minSdk instead. diff --git a/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/Testing.kt b/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/Testing.kt new file mode 100644 index 0000000..3cb9ea4 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/io/github/dewfallapp/buildlogic/Testing.kt @@ -0,0 +1,41 @@ +package io.github.dewfallapp.buildlogic + +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.withType + +/** + * Unit tests in plain Kotlin/JVM modules run on JUnit 6, through the JUnit Platform. Tests use + * kotlin.test's annotations and assertions, so they read the same as in Android modules. See + * docs/testing.md. + */ +internal fun Project.configureJvmUnitTests() { + tasks.withType().configureEach { useJUnitPlatform() } + + val libs = extensions.getByType().named("libs") + dependencies { + // The BOM also lifts the JUnit 5 artifacts that kotlin-test-junit5 asks for to JUnit 6. + add("testImplementation", platform(libs.findLibrary("junit-bom").get())) + add("testImplementation", libs.findLibrary("kotlin-test-junit5").get()) + add("testImplementation", libs.findLibrary("kotlinx-coroutines-test").get()) + // Gradle 9 no longer puts the JUnit Platform launcher on the test runtime classpath. + add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get()) + } +} + +/** + * Local unit tests in Android modules run on JUnit 4, AGP's default, because Robolectric and + * Compose's test rules need it. Tests use kotlin.test's annotations and assertions, as in plain + * Kotlin/JVM modules. See docs/testing.md. + */ +internal fun Project.configureAndroidUnitTests() { + val libs = extensions.getByType().named("libs") + dependencies { + add("testImplementation", libs.findLibrary("junit4").get()) + add("testImplementation", libs.findLibrary("kotlin-test-junit").get()) + add("testImplementation", libs.findLibrary("kotlinx-coroutines-test").get()) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dbb748c..65f43c7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,12 @@ ksp = "2.3.12" # Room 3, in the androidx.room3 package, for the runtime, the compiler and the Gradle plugin. # Room 2 is deprecated. See docs/architecture.md#build-settings. room3 = "3.0.3" +kotlinxCoroutines = "1.11.0" +# JUnit 6 runs the unit tests of the plain Kotlin/JVM modules. See docs/testing.md. +junit = "6.1.3" +# JUnit 4 runs the local unit tests of the Android modules, because Robolectric and Compose's +# test rules need it. See docs/testing.md. +junit4 = "4.13.2" [libraries] desugar-jdk-libs-nio = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugarJdkLibsNio" } @@ -40,6 +46,18 @@ hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hil androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } +# Unit test libraries. The convention plugins in build-logic add them to every module's tests. +# Tests use kotlin.test's annotations and assertions, which run on JUnit 6 in plain Kotlin/JVM +# modules and on JUnit 4 in Android modules. kotlin-test's JUnit 5 adapter also runs on JUnit 6. +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } +# The JUnit BOM sets the versions of the JUnit 6 artifacts below it, and of those that +# kotlin-test-junit5 brings in. +junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", version.ref = "kotlin" } +junit4 = { module = "junit:junit", version.ref = "junit4" } +kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } + # A detekt plugin. The detekt convention plugin in build-logic adds it to detekt in every project. compose-rules-detekt = { module = "io.nlopez.compose.rules:detekt", version.ref = "composeRules" } From 1b9698a04b49a5278c76770e7a661475df9535c2 Mon Sep 17 00:00:00 2001 From: Merajul Arefin Date: Thu, 15 Oct 2026 09:42:33 +0600 Subject: [PATCH 2/6] feat: add the MVI base classes - MviState, MviIntent and MviEffect mark each screen's types. The Mvi prefix keeps them from clashing with Compose's State and Android's Intent in screen code. - The reducer returns Next, the new state plus any one-time effects, so the decision to show an effect stays in pure, tested code. - MviViewModel runs every intent through the reducer and holds effects as pending state with unique ids until the UI calls effectHandled, as Android's UI events guidance recommends, so a stopped UI or a cancelled handler still gets them. - Work results come back through dispatch as intents, so every state change goes through the reducer. - The ViewModel base lives in the plain Kotlin/JVM module, built against Lifecycle's JVM build, whose ViewModel API matches the Android build. - A counter example in test code shows the whole pattern, with reducer and ViewModel tests. --- core/mvi/build.gradle.kts | 8 ++ .../io/github/dewfallapp/core/mvi/MviTypes.kt | 22 ++++ .../dewfallapp/core/mvi/MviViewModel.kt | 66 ++++++++++ .../dewfallapp/core/mvi/PendingEffect.kt | 7 + .../io/github/dewfallapp/core/mvi/Reducer.kt | 16 +++ .../dewfallapp/core/mvi/MviViewModelTest.kt | 121 ++++++++++++++++++ .../core/mvi/counter/CounterContract.kt | 33 +++++ .../core/mvi/counter/CounterReducer.kt | 26 ++++ .../core/mvi/counter/CounterReducerTest.kt | 40 ++++++ .../core/mvi/counter/CounterViewModel.kt | 20 +++ .../dewfallapp/core/mvi/counter/SavedCount.kt | 18 +++ gradle/libs.versions.toml | 6 + 12 files changed, 383 insertions(+) create mode 100644 core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/MviTypes.kt create mode 100644 core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/MviViewModel.kt create mode 100644 core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/PendingEffect.kt create mode 100644 core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/Reducer.kt create mode 100644 core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/MviViewModelTest.kt create mode 100644 core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterContract.kt create mode 100644 core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterReducer.kt create mode 100644 core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterReducerTest.kt create mode 100644 core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterViewModel.kt create mode 100644 core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/SavedCount.kt diff --git a/core/mvi/build.gradle.kts b/core/mvi/build.gradle.kts index f06fb6d..92fe78e 100644 --- a/core/mvi/build.gradle.kts +++ b/core/mvi/build.gradle.kts @@ -1,3 +1,11 @@ plugins { id("dewfall.jvm.library") } + +dependencies { + // MviViewModel extends Lifecycle's ViewModel and exposes StateFlows, so the modules that use it + // see both. This module compiles against Lifecycle's JVM build. Android modules get its Android + // build, which has the same API. + api(libs.androidx.lifecycle.viewmodel) + api(libs.kotlinx.coroutines.core) +} diff --git a/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/MviTypes.kt b/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/MviTypes.kt new file mode 100644 index 0000000..6c29340 --- /dev/null +++ b/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/MviTypes.kt @@ -0,0 +1,22 @@ +package io.github.dewfallapp.core.mvi + +/** + * A screen's state. Each screen declares one immutable class for it, usually a data class with val + * properties and read-only collections. Only the screen's reducer makes new ones. See + * docs/architecture.md#the-mvi-contract. + */ +interface MviState + +/** + * Something that happens to a screen: what the user does, or the result of work that the ViewModel + * ran, such as a load that finished. Each screen declares a sealed interface that extends this one, + * and names results in the past tense, such as Loaded. + */ +interface MviIntent + +/** + * A one-time instruction to the UI, such as showing a message or opening another screen. The + * ViewModel holds it as pending until the UI reports that it handled it. Each screen declares a + * sealed interface that extends this one. + */ +interface MviEffect diff --git a/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/MviViewModel.kt b/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/MviViewModel.kt new file mode 100644 index 0000000..0bf7515 --- /dev/null +++ b/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/MviViewModel.kt @@ -0,0 +1,66 @@ +package io.github.dewfallapp.core.mvi + +import androidx.lifecycle.ViewModel +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * The base class of every screen's ViewModel. It holds the screen's state and the effects the UI + * hasn't handled yet, and runs every intent through the screen's reducer. Subclasses start the work + * that intents need in [runSideEffects], and report results back through [dispatch]. + * + * Both the state and the pending effects live as long as the ViewModel. They survive configuration + * changes and the UI stopping, but not process death. See docs/architecture.md#the-mvi-contract. + */ +abstract class MviViewModel( + initialState: S, + private val reducer: Reducer, +) : ViewModel() { + + private val mutableState = MutableStateFlow(initialState) + + /** The screen's state. */ + val state: StateFlow = mutableState.asStateFlow() + + private val mutablePendingEffects = MutableStateFlow>>(emptyList()) + + /** + * The effects the UI hasn't handled yet, oldest first. The UI handles the first one, then calls + * [effectHandled] with its id. Until then, the effect stays here. + */ + val pendingEffects: StateFlow>> = mutablePendingEffects.asStateFlow() + + private val lastEffectId = AtomicLong() + + /** + * The single entry point for intents: what the user does, and the results of work that + * [runSideEffects] started. Runs the reducer, adds its effects to [pendingEffects], then calls + * [runSideEffects]. Call it on the main thread, as the UI and viewModelScope do. + */ + fun dispatch(intent: I) { + lateinit var next: Next + // update runs the reducer again if another thread changed the state in between. The + // reducer is pure, so that's safe, and only the result that update stores counts. + mutableState.update { current -> reducer.reduce(current, intent).also { next = it }.state } + if (next.effects.isNotEmpty()) { + val added = next.effects.map { PendingEffect(lastEffectId.incrementAndGet(), it) } + mutablePendingEffects.update { it + added } + } + runSideEffects(intent) + } + + /** Removes the pending effect with this id, once the UI has handled it. */ + fun effectHandled(id: Long) { + mutablePendingEffects.update { pending -> pending.filterNot { it.id == id } } + } + + /** + * Starts the work that an intent needs, such as a load, in viewModelScope. It runs after the + * reducer, so state.value is already the new state. Results come back through [dispatch] as + * intents of their own. + */ + protected abstract fun runSideEffects(intent: I) +} diff --git a/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/PendingEffect.kt b/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/PendingEffect.kt new file mode 100644 index 0000000..fba77ed --- /dev/null +++ b/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/PendingEffect.kt @@ -0,0 +1,7 @@ +package io.github.dewfallapp.core.mvi + +/** + * An effect that the UI hasn't handled yet. The id is unique within one ViewModel, so the UI can + * report exactly which effect it handled, even when two effects are equal. + */ +data class PendingEffect(val id: Long, val effect: E) diff --git a/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/Reducer.kt b/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/Reducer.kt new file mode 100644 index 0000000..38ab46d --- /dev/null +++ b/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/Reducer.kt @@ -0,0 +1,16 @@ +package io.github.dewfallapp.core.mvi + +/** + * Turns the old state and an intent into the new state and any one-time effects. It must be a pure + * function, with no coroutines, I/O, clock, randomness or shared mutable state, so it can be tested + * without a device. Every reducer has unit tests. + */ +fun interface Reducer { + fun reduce(state: S, intent: I): Next +} + +/** What a reducer returns: the new state, and the effects for the UI to handle, in order. */ +data class Next( + val state: S, + val effects: List = emptyList(), +) diff --git a/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/MviViewModelTest.kt b/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/MviViewModelTest.kt new file mode 100644 index 0000000..f000399 --- /dev/null +++ b/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/MviViewModelTest.kt @@ -0,0 +1,121 @@ +package io.github.dewfallapp.core.mvi + +import io.github.dewfallapp.core.mvi.counter.CounterEffect +import io.github.dewfallapp.core.mvi.counter.CounterIntent +import io.github.dewfallapp.core.mvi.counter.CounterReducer +import io.github.dewfallapp.core.mvi.counter.CounterState +import io.github.dewfallapp.core.mvi.counter.CounterViewModel +import io.github.dewfallapp.core.mvi.counter.SavedCount +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain + +/** Tests MviViewModel through the counter example in the counter package. */ +// setMain, resetMain, UnconfinedTestDispatcher and advanceUntilIdle are still marked experimental. +@OptIn(ExperimentalCoroutinesApi::class) +class MviViewModelTest { + @BeforeTest + fun setUp() { + // viewModelScope runs on Dispatchers.Main, which local tests don't have. Every + // TestDispatcher made after this, including runTest's, shares this one's scheduler. + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `dispatch runs the reducer and shows the new state`() = runTest { + val viewModel = counterViewModel() + + viewModel.dispatch(CounterIntent.Increment) + + assertEquals(CounterState(count = 1), viewModel.state.value) + } + + @Test + fun `a load result comes back through the reducer`() = runTest { + val viewModel = counterViewModel(savedCount = 2) + + viewModel.dispatch(CounterIntent.Load) + assertEquals(CounterState(isLoading = true), viewModel.state.value) + + // Runs the load. Its delay takes no real time. + advanceUntilIdle() + assertEquals(CounterState(count = 2), viewModel.state.value) + } + + @Test + fun `an effect stays pending until effectHandled`() = runTest { + val viewModel = counterViewModel() + viewModel.incrementToLimit() + + viewModel.dispatch(CounterIntent.Increment) + + // Reading the effect doesn't remove it, so a UI that stops, or a handler that's cancelled, + // gets it again. + val pending = viewModel.pendingEffects.value + assertEquals(listOf(CounterEffect.LimitReached), pending.map { it.effect }) + viewModel.dispatch(CounterIntent.Load) + assertEquals(pending, viewModel.pendingEffects.value) + + viewModel.effectHandled(pending.single().id) + + assertEquals(emptyList(), viewModel.pendingEffects.value) + } + + @Test + fun `effectHandled removes only that effect`() = runTest { + val viewModel = counterViewModel() + viewModel.incrementToLimit() + viewModel.dispatch(CounterIntent.Increment) + viewModel.dispatch(CounterIntent.Reset) + val (limitReached, wasReset) = viewModel.pendingEffects.value + + viewModel.effectHandled(wasReset.id) + + assertEquals(listOf(limitReached), viewModel.pendingEffects.value) + } + + @Test + fun `several effects keep their order`() = runTest { + val viewModel = counterViewModel() + viewModel.incrementToLimit() + + viewModel.dispatch(CounterIntent.Increment) + viewModel.dispatch(CounterIntent.Increment) + viewModel.dispatch(CounterIntent.Reset) + + val pending = viewModel.pendingEffects.value + assertEquals( + listOf( + CounterEffect.LimitReached, + CounterEffect.LimitReached, + CounterEffect.WasReset(CounterReducer.LIMIT), + ), + pending.map { it.effect }, + ) + // Equal effects still get their own ids, in the order they were added. + val ids = pending.map { it.id } + assertTrue(ids.zipWithNext().all { (earlier, later) -> earlier < later }) + } + + private fun TestScope.counterViewModel(savedCount: Int = 0) = + CounterViewModel(SavedCount(savedCount, StandardTestDispatcher(testScheduler))) + + private fun CounterViewModel.incrementToLimit() = + repeat(CounterReducer.LIMIT) { dispatch(CounterIntent.Increment) } +} diff --git a/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterContract.kt b/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterContract.kt new file mode 100644 index 0000000..52188f1 --- /dev/null +++ b/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterContract.kt @@ -0,0 +1,33 @@ +package io.github.dewfallapp.core.mvi.counter + +import io.github.dewfallapp.core.mvi.MviEffect +import io.github.dewfallapp.core.mvi.MviIntent +import io.github.dewfallapp.core.mvi.MviState + +// A small example screen that shows the whole MVI pattern. It lives in test code only. In the app, +// a feature's State, Intent, Effect and reducer go in its -logic module, and its ViewModel in its +// -ui module. See docs/architecture.md#the-mvi-contract. + +data class CounterState(val count: Int = 0, val isLoading: Boolean = false) : MviState + +sealed interface CounterIntent : MviIntent { + /** The user taps the plus button. */ + data object Increment : CounterIntent + + /** The user taps the reset button. */ + data object Reset : CounterIntent + + /** The user asks for the saved count. */ + data object Load : CounterIntent + + /** The saved count arrived. CounterViewModel sends this when its load finishes. */ + data class Loaded(val count: Int) : CounterIntent +} + +sealed interface CounterEffect : MviEffect { + /** Tell the user that the count can't go higher. */ + data object LimitReached : CounterEffect + + /** Tell the user what the count was before the reset. */ + data class WasReset(val previousCount: Int) : CounterEffect +} diff --git a/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterReducer.kt b/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterReducer.kt new file mode 100644 index 0000000..5a33f0b --- /dev/null +++ b/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterReducer.kt @@ -0,0 +1,26 @@ +package io.github.dewfallapp.core.mvi.counter + +import io.github.dewfallapp.core.mvi.Next +import io.github.dewfallapp.core.mvi.Reducer + +/** Counts up to [LIMIT], then reports [CounterEffect.LimitReached] instead. */ +object CounterReducer : Reducer { + const val LIMIT = 3 + + override fun reduce( + state: CounterState, + intent: CounterIntent, + ): Next = + when (intent) { + CounterIntent.Increment -> + if (state.count < LIMIT) { + Next(state.copy(count = state.count + 1)) + } else { + Next(state, listOf(CounterEffect.LimitReached)) + } + CounterIntent.Reset -> + Next(state.copy(count = 0), listOf(CounterEffect.WasReset(state.count))) + CounterIntent.Load -> Next(state.copy(isLoading = true)) + is CounterIntent.Loaded -> Next(state.copy(count = intent.count, isLoading = false)) + } +} diff --git a/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterReducerTest.kt b/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterReducerTest.kt new file mode 100644 index 0000000..34fa602 --- /dev/null +++ b/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterReducerTest.kt @@ -0,0 +1,40 @@ +package io.github.dewfallapp.core.mvi.counter + +import io.github.dewfallapp.core.mvi.Next +import kotlin.test.Test +import kotlin.test.assertEquals + +/** A reducer is a plain function, so its tests need no coroutines and no ViewModel. */ +class CounterReducerTest { + @Test + fun `increment adds one`() { + val next = CounterReducer.reduce(CounterState(count = 1), CounterIntent.Increment) + + assertEquals(Next(CounterState(count = 2)), next) + } + + @Test + fun `increment at the limit keeps the count and reports LimitReached`() { + val atLimit = CounterState(count = CounterReducer.LIMIT) + + val next = CounterReducer.reduce(atLimit, CounterIntent.Increment) + + assertEquals(Next(atLimit, listOf(CounterEffect.LimitReached)), next) + } + + @Test + fun `reset sets the count to zero and reports the old count`() { + val next = CounterReducer.reduce(CounterState(count = 2), CounterIntent.Reset) + + assertEquals(Next(CounterState(count = 0), listOf(CounterEffect.WasReset(2))), next) + } + + @Test + fun `load shows loading until loaded sets the count`() { + val loading = CounterReducer.reduce(CounterState(), CounterIntent.Load) + assertEquals(Next(CounterState(isLoading = true)), loading) + + val loaded = CounterReducer.reduce(loading.state, CounterIntent.Loaded(count = 2)) + assertEquals(Next(CounterState(count = 2)), loaded) + } +} diff --git a/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterViewModel.kt b/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterViewModel.kt new file mode 100644 index 0000000..63f01b3 --- /dev/null +++ b/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterViewModel.kt @@ -0,0 +1,20 @@ +package io.github.dewfallapp.core.mvi.counter + +import androidx.lifecycle.viewModelScope +import io.github.dewfallapp.core.mvi.MviViewModel +import kotlinx.coroutines.launch + +class CounterViewModel(private val savedCount: SavedCount) : + MviViewModel(CounterState(), CounterReducer) { + + override fun runSideEffects(intent: CounterIntent) { + when (intent) { + // The reducer has already shown loading. The result goes back through the reducer. + CounterIntent.Load -> + viewModelScope.launch { dispatch(CounterIntent.Loaded(savedCount.load())) } + CounterIntent.Increment, + CounterIntent.Reset, + is CounterIntent.Loaded -> Unit + } + } +} diff --git a/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/SavedCount.kt b/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/SavedCount.kt new file mode 100644 index 0000000..cc8fc77 --- /dev/null +++ b/core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/SavedCount.kt @@ -0,0 +1,18 @@ +package io.github.dewfallapp.core.mvi.counter + +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +/** + * Stands in for a repository. Like a real one, it moves its work to the dispatcher it receives, so + * a test can pass a TestDispatcher, and the delay takes no real time. + */ +class SavedCount(private val count: Int, private val dispatcher: CoroutineDispatcher) { + suspend fun load(): Int = + withContext(dispatcher) { + delay(1.seconds) + count + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 65f43c7..04c9da9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ ksp = "2.3.12" # Room 3, in the androidx.room3 package, for the runtime, the compiler and the Gradle plugin. # Room 2 is deprecated. See docs/architecture.md#build-settings. room3 = "3.0.3" +lifecycle = "2.11.0" kotlinxCoroutines = "1.11.0" # JUnit 6 runs the unit tests of the plain Kotlin/JVM modules. See docs/testing.md. junit = "6.1.3" @@ -46,6 +47,11 @@ hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hil androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } +# :core:mvi's ViewModel base. A plain Kotlin/JVM module gets Lifecycle's JVM build, and an Android +# module gets its Android build. See docs/architecture.md#the-mvi-contract. +androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } + # Unit test libraries. The convention plugins in build-logic add them to every module's tests. # Tests use kotlin.test's annotations and assertions, which run on JUnit 6 in plain Kotlin/JVM # modules and on JUnit 4 in Android modules. kotlin-test's JUnit 5 adapter also runs on JUnit 6. From 55d60674cff3061289c69e99e7fcc039b3988b5d Mon Sep 17 00:00:00 2001 From: Merajul Arefin Date: Thu, 15 Oct 2026 09:42:40 +0600 Subject: [PATCH 3/6] docs: let reducers return effects and deliver them as state - The reducer returns the new state and any one-time effects, so effect decisions stay in pure code that unit tests cover. - Intents include the results of work the ViewModel runs, so a finished load reaches the screen through the reducer. - Effects stay pending as UI state until the UI reports them handled, as Android's UI events guidance recommends, because a channel doesn't guarantee delivery when the ViewModel outlives the UI. - Fix the roadmap's description of a reducer to match. --- docs/adr/0002-mvi-with-playback-state-outside.md | 8 +++++--- docs/roadmap.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/adr/0002-mvi-with-playback-state-outside.md b/docs/adr/0002-mvi-with-playback-state-outside.md index 5f06003..a0b1de7 100644 --- a/docs/adr/0002-mvi-with-playback-state-outside.md +++ b/docs/adr/0002-mvi-with-playback-state-outside.md @@ -4,6 +4,8 @@ Accepted +Changed in #136: the reducer can return effects, intents include work results, and effects are delivered as state. + ## Context Every screen needs a clear way to hold its state and handle what the user does. Screen logic should be easy to test without a phone. @@ -24,9 +26,9 @@ Video playback is different from the rest of the screen. The playback position c Every screen uses MVI: - An immutable State. -- An Intent sealed interface for what the user does. -- One-time Effects. -- A reducer, a pure function that takes the old state and an intent and returns a new state. +- An Intent sealed interface for what the user does, and for the results of work the ViewModel runs, such as a load that finished. +- One-time Effects, held as pending UI state until the UI reports that it handled them, as Android's [UI events guidance](https://developer.android.com/topic/architecture/ui-layer/events) recommends. +- A reducer, a pure function that takes the old state and an intent and returns the new state and any one-time effects. MVI still uses Android's ViewModel. The ViewModel runs side effects. Every reducer has unit tests. diff --git a/docs/roadmap.md b/docs/roadmap.md index b8b255a..1843130 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -154,7 +154,7 @@ Claude Design exports as .zip, PDF, PPTX, Google Slides or standalone HTML. It c The tool names in this plan come from memory. Check that each one is still maintained before you adopt it. -A reducer is a plain function that takes the old state and an intent and returns a new state. Most screen logic can then be tested in milliseconds without a phone. Write a test for the MVI base right away so the pattern is clear for later screens. +A reducer is a plain function that takes the old state and an intent and returns a new state and any one-time effects. Most screen logic can then be tested in milliseconds without a phone. Write a test for the MVI base right away so the pattern is clear for later screens. Once the theme exists in code, Claude Design can import a design system from a GitHub repository. In Claude Code, the /design-sync command pulls the design system into Claude Design, and /design creates and syncs design projects from the terminal. From 9bf33d44ea428e4677d88357d147859d4d9b924f Mon Sep 17 00:00:00 2001 From: Merajul Arefin Date: Thu, 15 Oct 2026 09:42:40 +0600 Subject: [PATCH 4/6] docs: describe the MVI contract and how to run tests - Fill in the MVI contract in architecture.md: the types, how an intent flows, effects as pending state, dispatchers, and why the ViewModel base fits in a plain Kotlin/JVM module. - Explain the two JUnit versions in the build settings, and what the Android API check does and doesn't check now that a module has code. - Fill in Running tests locally in testing.md, so later screens follow the same test pattern. - Add the MVI and test rules, and the one-module test commands, to AGENTS.md. --- AGENTS.md | 4 ++ docs/architecture.md | 110 +++++++++++++++++++++++++++++++++++++++---- docs/testing.md | 33 ++++++++++++- 3 files changed, 138 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6c8ec86..23aa9bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,7 @@ These apply from Phase 2 on. If Phase 2 refines them, update this section in the - Code that needs nothing from Android lives in plain Kotlin/JVM modules with no Android imports: the YouTube layer, the domain models, the MVI base types and the reducers. See ADR 0006. - Only the YouTube layer module may use NewPipeExtractor. It maps every NewPipeExtractor type to Dewfall's own models, so nothing else in the app knows which library sits underneath. - Every screen uses MVI: an immutable State, an Intent sealed interface, one-time Effects, and a reducer that is a pure function. The ViewModel runs side effects. Every reducer has unit tests. +- Every screen's ViewModel extends MviViewModel from `:core:mvi`. Intents include the results of the ViewModel's work, and effects stay pending as state until the UI reports them handled. docs/architecture.md describes the MVI contract. - Playback state never goes into screen state. The player lives in a MediaSessionService and exposes playback state as its own Flow. Position updates run only as often as the visible UI needs them. - Download, sync and cleanup logic must not depend on any screen being open, because background WorkManager jobs reuse it. Background jobs run only on unmetered networks while charging. - No hardcoded user-facing text. Every string goes in resources, and every icon has a content description. @@ -100,6 +101,8 @@ These apply from Phase 2 on. If Phase 2 refines them, update this section in the ## Testing rules - New logic ships with tests in the same pull request. +- Write tests with kotlin.test's annotations and assertions. They run on JUnit 6 in plain Kotlin/JVM modules and on JUnit 4 in Android modules. docs/testing.md explains how to test reducers and ViewModels. +- ViewModel tests replace the Main dispatcher with Dispatchers.setMain before each test and reset it after. - Unit tests never touch the network. The YouTube layer is tested against saved responses. Live YouTube checks run only in the scheduled CI workflow. - Screens get screenshot tests in light, dark and large font. - Never delete, skip or weaken a test to make it pass. If a test looks wrong, explain why and ask. @@ -110,6 +113,7 @@ Run these from the repository root. The build needs JDK 17 installed, because ev - Build the debug APK: `./gradlew assembleDebug`. The APK goes to app/build/outputs/apk/debug/app-debug.apk. - Run the unit tests: `./gradlew test`. It runs the tests of the plain Kotlin/JVM modules and the debug unit tests of the Android modules. +- Run one module's unit tests: `./gradlew :core:mvi:test` for a plain Kotlin/JVM module, or `./gradlew :core:data:testDebugUnitTest` for an Android module. Add `--tests` with a class name to run one test class. The HTML report goes to build/reports/tests/ in the module. - Check formatting: `./gradlew spotlessCheck`. It checks every Kotlin file and Kotlin build script in the repository, build-logic included, against ktfmt in kotlinlang style. - Fix formatting: `./gradlew spotlessApply`. It rewrites the same files in place. Run it before you commit. - Run detekt: `./gradlew detekt`. It checks the Kotlin code in src/main and src/test of every module, and the code in build-logic, without type resolution. Any finding fails the build. Reports go to build/reports/detekt/ in each project. diff --git a/docs/architecture.md b/docs/architecture.md index a141425..71544bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,13 +54,13 @@ Android modules set a namespace. Plain Kotlin/JVM modules have no namespace, so |---|---|---|---|---|---| | `:app` | app/ | Android app | io.github.dewfallapp | The entry point, which ties the other modules together. It holds the Hilt Application class and the app's only activity, which shows a placeholder screen until the first feature arrives. Once features arrive, it also handles navigation between them. | `:core:data`, `:core:designsystem`, and each feature's `-ui` module | | `:core:model` | core/model/ | Kotlin/JVM | io.github.dewfallapp.core.model | Domain models, such as videos, channels and subtitle lines | Nothing | -| `:core:mvi` | core/mvi/ | Kotlin/JVM | io.github.dewfallapp.core.mvi | The MVI base types: State, Intent, Effect and the reducer | Nothing | +| `:core:mvi` | core/mvi/ | Kotlin/JVM | io.github.dewfallapp.core.mvi | The MVI base: MviState, MviIntent, MviEffect, the Reducer, and MviViewModel, the base class of every screen's ViewModel. See [The MVI contract](#the-mvi-contract). | Nothing. It uses Lifecycle's ViewModel and kotlinx.coroutines. | | `:core:youtube` | core/youtube/ | Kotlin/JVM | io.github.dewfallapp.core.youtube | The YouTube layer: Dewfall's interfaces for search, video details, a channel's latest uploads, stream links and subtitles, and their NewPipeExtractor implementation. It is the only module that may use NewPipeExtractor. See [ADR 0001](adr/0001-newpipeextractor-behind-youtube-layer.md). | `:core:model` | | `:core:database` | core/database/ | Android library | io.github.dewfallapp.core.database | The Room database: entities, DAOs, migrations and the exported schemas | `:core:model` | | `:core:data` | core/data/ | Android library | io.github.dewfallapp.core.data | Repositories that combine the database and the YouTube layer | `:core:model`, `:core:database`, `:core:youtube` | | `:core:designsystem` | core/designsystem/ | Android library | io.github.dewfallapp.core.designsystem | The Compose theme built from the design tokens, plus shared components and icons | Nothing | -The core modules hold no code yet. `:core:database` has Room set up, but no database until Phase 5 adds the first tables (#48). Later tasks fill the other modules: the MVI base types in #25, the theme in #26, and the YouTube layer in Phase 3. Compose and Hilt are set up in `:app` only, until a core module needs them. +`:core:mvi` holds the MVI base. The other core modules hold no code yet. `:core:database` has Room set up, but no database until Phase 5 adds the first tables (#48). Later tasks fill the other modules: the theme in #26, and the YouTube layer in Phase 3. Compose and Hilt are set up in `:app` only, until a core module needs them. `:core:designsystem` holds the theme and the components that know nothing about Dewfall's data. Its components take plain values, such as text, images and click handlers. Shared UI that needs the domain models or string resources, such as a video row that several features use, or the code that formats a duration, goes in a `:core:ui` Android library instead. `:core:ui` gets created when a second feature needs such a piece. It may depend on `:core:designsystem` and `:core:model`, and feature `-ui` modules may depend on it. @@ -74,7 +74,7 @@ Each feature has two modules, because its reducer has to be plain Kotlin/JVM and | `:feature:inbox-ui` | feature/inbox-ui/ | Android library | io.github.dewfallapp.feature.inbox.ui | The Compose screens, InboxViewModel, string resources and screenshot tests | `:feature:inbox-logic`, `:core:data`, `:core:designsystem`, `:core:mvi`, `:core:model` | - Feature modules depend on core modules, never on another feature's modules. A feature's `-ui` module also depends on its own `-logic` module. -- The ViewModel goes in the `-ui` module. It runs side effects through `:core:data` and uses Hilt, and both are Android. A plain Kotlin/JVM module can't depend on an Android module. +- The ViewModel goes in the `-ui` module and extends MviViewModel from `:core:mvi`. It runs side effects through `:core:data` and uses Hilt, and both are Android. A plain Kotlin/JVM module can't depend on an Android module. - `:app` depends on every feature's `-ui` module and handles navigation between features. ### Module names @@ -87,9 +87,9 @@ Every module applies one of three convention plugins from build-logic/. They hol | Plugin | For | What it sets | |---|---|---| -| dewfall.android.application | `:app` | compileSdk 37, minSdk 29 and targetSdk 37, as ADR 0006 decides, the JDK 17 toolchain, and the shared Kotlin compiler, lint and detekt settings | -| dewfall.android.library | Android libraries | compileSdk 37 and minSdk 29, targetSdk 37 for lint and tests, the JDK 17 toolchain, and the shared Kotlin compiler, lint and detekt settings | -| dewfall.jvm.library | Plain Kotlin/JVM modules | The Kotlin JVM plugin, the JDK 17 toolchain, the shared Kotlin compiler, lint and detekt settings, and the Android API check | +| dewfall.android.application | `:app` | compileSdk 37, minSdk 29 and targetSdk 37, as ADR 0006 decides, the JDK 17 toolchain, the shared Kotlin compiler, lint and detekt settings, and JUnit 4 unit tests | +| dewfall.android.library | Android libraries | compileSdk 37 and minSdk 29, targetSdk 37 for lint and tests, the JDK 17 toolchain, the shared Kotlin compiler, lint and detekt settings, and JUnit 4 unit tests | +| dewfall.jvm.library | Plain Kotlin/JVM modules | The Kotlin JVM plugin, the JDK 17 toolchain, the shared Kotlin compiler, lint and detekt settings, the Android API check, and JUnit 6 unit tests | A fourth plugin, dewfall.detekt, holds the detekt settings and adds the compose-rules plugin to detekt. The three plugins above apply it, and so does the root project, to check the code in build-logic. @@ -115,6 +115,13 @@ Every module compiles with JDK 17, and the Java and Kotlin bytecode targets foll Kotlin compiler warnings fail the build in every module, so a call to a deprecated API stops the build. KotlinCompiler.kt in build-logic turns on the compiler's [allWarningsAsErrors](https://kotlinlang.org/docs/gradle-compiler-options.html#attributes-common-to-jvm-and-javascript) option in the module's `kotlin.compilerOptions` block, which reaches every Kotlin compilation in the module, tests included ([Target the JVM](https://kotlinlang.org/docs/gradle-compiler-options.html#target-the-jvm)). In Android modules, AGP's built-in Kotlin provides that block ([Migrate to built-in Kotlin](https://developer.android.com/build/migrate-to-built-in-kotlin#migration-steps-migrate-kotlin-options)). In plain Kotlin/JVM modules, the Kotlin JVM plugin does. The code in build-logic is a separate build, so the setting doesn't cover it. KSP has a setting of its own, which dewfall.android.hilt and dewfall.android.room turn on through KotlinSymbolProcessing.kt, so warnings that Hilt or Room report while KSP runs fail the build too. javac compiles the Java code that Hilt generates, and builds the Hilt components in the Hilt Gradle plugin's own task. Its warnings, such as Dagger's warnings about the dependency graph, don't fail the build. If a warning can't be fixed, suppress it with @Suppress on the single statement or declaration, never a whole file, with a comment that says why and names the issue that will remove it, if there is one. +**Unit tests.** Testing.kt in build-logic sets up every module's unit tests, so a new module gets them by applying its plugin. Tests use [kotlin.test](https://kotlinlang.org/api/core/kotlin-test/)'s annotations and assertions everywhere, so they read the same in every module. Underneath, two versions of JUnit run them: + +- Plain Kotlin/JVM modules run JUnit 6 through the JUnit Platform. Kotlin's docs use JUnit 6 with kotlin.test ([Test code using JUnit](https://kotlinlang.org/docs/jvm-test-using-junit.html)). Gradle 9 no longer puts the JUnit Platform launcher on the test classpath by itself, so Testing.kt adds it ([Gradle upgrade guide](https://docs.gradle.org/9.8.1/userguide/upgrading_version_8.html)). +- Android modules run JUnit 4, AGP's default and the framework in Android's [local test guide](https://developer.android.com/training/testing/local-tests). Robolectric supports only JUnit 4 ([robolectric#3477](https://github.com/robolectric/robolectric/issues/3477)), Compose's test rules are JUnit 4 rules, and JUnit 6 deprecates the engine that would run JUnit 4 tests on it ([JUnit 6.0.0](https://docs.junit.org/6.0.0/release-notes/)). + +Both get kotlinx-coroutines-test. [testing.md](testing.md#running-tests-locally) explains how to write and run the tests. + The versions of every plugin and library are in gradle/libs.versions.toml. To add a module, include it in settings.gradle.kts, apply one of the three plugins in its build.gradle.kts, add dewfall.android.compose, dewfall.android.hilt or dewfall.android.room if it uses them, and set a namespace if it is an Android module. ### Formatting and lint @@ -129,7 +136,7 @@ Four tools check the code. AGENTS.md lists the commands that run them. **Android Lint.** Lint checks every module, the plain Kotlin/JVM ones through the com.android.lint plugin. Any warning fails the build. Each module checks only its own code. The checks that look up versions or library data on the network are off, so lint gives the same result on every run. AndroidLint.kt in build-logic lists them. The app's manifest suppresses two checks until the tasks that fix them are done: DataExtractionRules (#68) and MissingApplicationIcon (#120). -**The Android API check.** The plain Kotlin/JVM modules compile and run their tests against the full JDK, so a call to a Java API that Android 10 lacks passes there and fails on a phone ([ADR 0006](adr/0006-platform-and-android-versions.md)). Lint can't catch this in these modules. AGP runs lint one module at a time, and lint's API check skips any module that isn't Android ([ApiDetector](https://android.googlesource.com/platform/tools/base/+/refs/heads/mirror-goog-studio-main/lint/libs/lint-checks/src/main/java/com/android/tools/lint/checks/ApiDetector.kt)). So [Animal Sniffer](https://github.com/xvik/gradle-animalsniffer-plugin) checks each of these modules' compiled classes against a [Gummy Bears](https://github.com/open-toast/gummy-bears) signature of the Android API. The signature's API level comes from the minSdk in build-logic, so the check follows it. The signature includes the methods that D8 backports, but not the APIs that core library desugaring adds, because Gummy Bears publishes no such signature for API 29. So the check also flags an API that only desugaring provides. If code needs such an API, check that Android's [desugaring table](https://developer.android.com/studio/write/java11-nio-support-table) lists it, then allow it in configureAndroidApiCheck with Animal Sniffer's ignore or annotation setting, and say why in a comment. It checks the module's own classes, not its dependencies such as NewPipeExtractor. `./gradlew lint` runs it too. +**The Android API check.** The plain Kotlin/JVM modules compile and run their tests against the full JDK, so a call to a Java API that Android 10 lacks passes there and fails on a phone ([ADR 0006](adr/0006-platform-and-android-versions.md)). Lint can't catch this in these modules. AGP runs lint one module at a time, and lint's API check skips any module that isn't Android ([ApiDetector](https://android.googlesource.com/platform/tools/base/+/refs/heads/mirror-goog-studio-main/lint/libs/lint-checks/src/main/java/com/android/tools/lint/checks/ApiDetector.kt)). So [Animal Sniffer](https://github.com/xvik/gradle-animalsniffer-plugin) checks each of these modules' compiled classes against a [Gummy Bears](https://github.com/open-toast/gummy-bears) signature of the Android API. The signature's API level comes from the minSdk in build-logic, so the check follows it. The signature includes the methods that D8 backports, but not the APIs that core library desugaring adds, because Gummy Bears publishes no such signature for API 29. So the check also flags an API that only desugaring provides. If code needs such an API, check that Android's [desugaring table](https://developer.android.com/studio/write/java11-nio-support-table) lists it, then allow it in configureAndroidApiCheck with Animal Sniffer's ignore or annotation setting, and say why in a comment. It checks the module's own classes, not its dependencies such as NewPipeExtractor. Calls into a dependency aren't checked either, because Animal Sniffer allows every package it finds on the compile classpath, such as kotlinx.coroutines or androidx.lifecycle. So it checks calls to the JDK. Test code never ships, so it isn't checked, and a module with no classes has nothing to check. Kotlin compiles string templates, such as the toString of a data class, to invokedynamic calls through StringConcatFactory, which Android 10 lacks. Animal Sniffer checks only the target method of a lambda's invokedynamic call, so it passes these. That is right, because D8 rewrites them into StringBuilder calls ([StringConcatInstructionDesugaring](https://r8.googlesource.com/r8/+/refs/heads/main/src/main/java/com/android/tools/r8/ir/desugar/stringconcat/StringConcatInstructionDesugaring.java)), and lambdas work on any API level ([Java 8+ language features](https://developer.android.com/studio/write/java8-support)). `./gradlew lint` runs it too. ## Network access @@ -143,7 +150,94 @@ TODO ## The MVI contract -TODO +Every screen uses MVI, as [ADR 0002](adr/0002-mvi-with-playback-state-outside.md) decides. `:core:mvi` holds the base types that every screen builds on. A small example screen, a counter, lives in core/mvi/src/test/ and shows the whole pattern with its tests. + +### The types + +| Type | What it is | +|---|---| +| MviState | A screen's state. One immutable class per screen, usually a data class with val properties and read-only collections. Only the screen's reducer makes new ones. | +| MviIntent | Something that happens to the screen: what the user does, or the result of work the ViewModel ran. A screen's intents form a sealed interface. Results are named in the past tense, such as Loaded. | +| MviEffect | A one-time instruction to the UI, such as showing a message or opening another screen. A screen's effects form a sealed interface. | +| Reducer | A pure function from the old state and an intent to a Next: the new state and any effects, in order. It runs no coroutines, I/O, clock, randomness or shared mutable state. | +| PendingEffect | An effect that the UI hasn't handled yet, with an id that is unique within its ViewModel. | +| MviViewModel | The base class of every screen's ViewModel. It holds the state and the pending effects, and runs every intent through the reducer. | + +A feature's State, Intent, Effect and reducer go in its `-logic` module. Its ViewModel extends MviViewModel and goes in its `-ui` module. The base type names start with Mvi, so they don't clash with Compose's State or Android's Intent. + +### How an intent flows + +```mermaid +sequenceDiagram + participant UI as Screen + participant VM as MviViewModel + participant R as Reducer + participant D as Repository + UI->>VM: dispatch(Load) + VM->>R: reduce(state, Load) + R-->>VM: Next(state with loading shown) + VM-->>UI: state + VM->>D: runSideEffects(Load) starts the load + D-->>VM: the loaded data + VM->>R: reduce(state, Loaded) + R-->>VM: Next(new state, effects) + VM-->>UI: state and pending effects +``` + +1. The screen calls dispatch with an intent, such as Load. +2. dispatch runs the reducer. The new state replaces the old one in `state`, and the reducer's effects join the end of `pendingEffects`. +3. dispatch then calls the ViewModel's runSideEffects. It starts the work that the intent needs in viewModelScope, such as a load through `:core:data`. Intents that need no work do nothing there. +4. When the work finishes, the ViewModel calls dispatch again with the result, such as Loaded. The result goes through the reducer like any other intent. + +So every change to the state comes from the reducer, and the reducer's unit tests cover most of the screen's logic. The ViewModel's tests cover which work starts for which intent. dispatch runs on the main thread: the UI calls it there, and viewModelScope runs on Dispatchers.Main.immediate. + +### Effects + +Android's [UI events guidance](https://developer.android.com/topic/architecture/ui-layer/events) says that events from a ViewModel should become UI state, because a channel or stream doesn't guarantee that the UI receives and handles each event when the ViewModel outlives the UI. So MviViewModel holds effects as state. Each effect waits in `pendingEffects`, a StateFlow next to `state`, until the UI says it handled it. + +- The UI handles the first pending effect, then calls effectHandled with its id. That removes only that effect, and the next one becomes first, so effects are handled in order. +- An effect stays pending until then. If the UI stops before it handles the effect, or its handler is cancelled halfway, such as a message that was showing during a rotation, the UI gets the effect again. +- Pending effects survive configuration changes and the UI stopping, because they live in the ViewModel. They don't survive process death. Anything that must survive process death belongs in the screen's state, saved with [SavedStateHandle](https://developer.android.com/topic/libraries/architecture/viewmodel/viewmodel-savedstate). + +The UI side comes with the first screen in Phase 4. It will look like this sketch. It waits until the screen is started, handles the effect, then reports it. It is keyed by the effect's id, so a recomposition doesn't handle the same effect twice. + +```kotlin +@Composable +fun HandleEffects( + pendingEffects: List>, + onEffectHandled: (id: Long) -> Unit, + handle: suspend (E) -> Unit, +) { + val first = pendingEffects.firstOrNull() + val lifecycle = LocalLifecycleOwner.current.lifecycle + val currentHandle by rememberUpdatedState(handle) + val currentOnEffectHandled by rememberUpdatedState(onEffectHandled) + if (first != null) { + LaunchedEffect(first.id, lifecycle) { + lifecycle.currentStateFlow.first { it.isAtLeast(Lifecycle.State.STARTED) } + currentHandle(first.effect) + currentOnEffectHandled(first.id) + } + } +} +``` + +A screen calls it with `viewModel.pendingEffects.collectAsStateWithLifecycle().value` and `viewModel::effectHandled`. + +### Side effects and dispatchers + +- MviViewModel takes no dispatcher. viewModelScope runs on Dispatchers.Main.immediate, and tests replace Main with Dispatchers.setMain, as Google's [coroutine testing guide](https://developer.android.com/kotlin/coroutines/test#setting-main-dispatcher) does. +- Code that moves work to another thread, such as a repository, receives a CoroutineDispatcher in its constructor, so a test can pass a TestDispatcher. Its suspend functions are safe to call from the main thread, so the ViewModel calls them from viewModelScope without switching threads ([coroutines best practices](https://developer.android.com/kotlin/coroutines/coroutines-best-practices)). +- The reducer never runs side effects. Only runSideEffects starts work. +- Playback state never goes into screen state. A screen that shows playback reads two sources: its own state, and the player's playback stream. See [ADR 0002](adr/0002-mvi-with-playback-state-outside.md). + +### Why the ViewModel base is in a plain Kotlin/JVM module + +[ADR 0006](adr/0006-platform-and-android-versions.md) puts the MVI base types in a plain Kotlin/JVM module, and MviViewModel can live there too. Lifecycle publishes a JVM build of lifecycle-viewmodel, lifecycle-viewmodel-desktop, and `:core:mvi` compiles against it. Android modules get the Android build, lifecycle-viewmodel-android, instead. Both builds come from the same source files, and their ViewModel and viewModelScope APIs are the same, so classes compiled against one run with the other. The Android build only adds AndroidViewModel and helpers for Android's View tree, which `:core:mvi` doesn't use. + +`:core:mvi` exposes Lifecycle and kotlinx.coroutines as api dependencies, because MviViewModel extends ViewModel and exposes StateFlows. So `-logic` modules can see ViewModel too. They don't use it, because ViewModels go in `-ui` modules. + +On the computer's JVM there's no Main dispatcher, and viewModelScope then quietly falls back to EmptyCoroutineContext, so its coroutines run on Dispatchers.Default's threads ([Lifecycle 2.8.0](https://developer.android.com/jetpack/androidx/releases/lifecycle#2.8.0)). That's why every ViewModel test sets Main first, as [testing.md](testing.md#running-tests-locally) shows. ## The player diff --git a/docs/testing.md b/docs/testing.md index 2679b46..dbc86b4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -51,7 +51,38 @@ Workflow 1 is .github/workflows/pull-request.yml. Today it runs one job, Checks, ## Running tests locally -TODO +Run these from the repository root. AGENTS.md lists the command that runs every check the pull request workflow runs. + +| To run | Command | +|---|---| +| Every unit test | `./gradlew test` | +| One plain Kotlin/JVM module's tests | `./gradlew :core:mvi:test` | +| One Android module's tests | `./gradlew :core:data:testDebugUnitTest` | +| One test class | Add `--tests` with the class name, such as `./gradlew :core:mvi:test --tests "io.github.dewfallapp.core.mvi.counter.CounterReducerTest"` | + +Each module writes an HTML report to build/reports/tests/test/index.html in a plain Kotlin/JVM module, or to build/reports/tests/testDebugUnitTest/index.html in an Android module. The XML results go to build/test-results/. AGP 9 creates unit tests for the debug build type only, so Android modules have no release unit tests. + +### Writing tests + +- Use kotlin.test's annotations and assertions, such as kotlin.test.Test, BeforeTest, AfterTest and assertEquals. They run on JUnit 6 in plain Kotlin/JVM modules and on JUnit 4 in Android modules, so a test reads the same in both. [architecture.md](architecture.md#build-settings) explains why there are two. +- Name each test with backticks, as a sentence about what it checks, such as `` `increment adds one` ``. detekt's naming rule skips test folders. +- Put tests in the src/test/ folder of the module whose code they test. + +### Reducers + +A reducer is a plain function, so its test calls reduce and compares the Next it returns. It needs no coroutines and no ViewModel. [CounterReducerTest.kt](../core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/counter/CounterReducerTest.kt) shows the pattern. + +### ViewModels and coroutines + +ViewModel tests use kotlinx-coroutines-test, as Google's guides describe ([Testing Kotlin coroutines](https://developer.android.com/kotlin/coroutines/test), [Testing Kotlin flows](https://developer.android.com/kotlin/flow/test)). [MviViewModelTest.kt](../core/mvi/src/test/kotlin/io/github/dewfallapp/core/mvi/MviViewModelTest.kt) shows the pattern. + +- Replace the Main dispatcher with `Dispatchers.setMain(UnconfinedTestDispatcher())` in a @BeforeTest function, and reset it with `Dispatchers.resetMain()` in an @AfterTest function. viewModelScope runs on Main. Without a Main dispatcher, its coroutines run on Dispatchers.Default's threads, and the test becomes flaky. +- Wrap each test in runTest. It shares the Main dispatcher's scheduler and skips delays. +- Where the code under test takes a dispatcher, pass `StandardTestDispatcher(testScheduler)`, so every TestDispatcher in the test shares one scheduler. Call advanceUntilIdle() to run the work it queued. +- Read a ViewModel's state and pendingEffects through their value. Both are StateFlows that hold their latest value, so the test needs no collector. +- To check every value a Flow emits over time, collect it in backgroundScope, as Google's flow testing guide shows. The project doesn't use Turbine. +- setMain, resetMain, UnconfinedTestDispatcher and advanceUntilIdle are marked experimental, and compiler warnings fail the build. So annotate the test class with `@OptIn(ExperimentalCoroutinesApi::class)`. +- Test which work each intent starts, and that its result comes back through the reducer. The reducer's own tests cover the rest of the screen's logic. ## Saved YouTube responses From 84ffb4ad7e082fc3ba1ef3d7e43c2277726d3d42 Mon Sep 17 00:00:00 2001 From: Merajul Arefin Date: Thu, 15 Oct 2026 09:54:17 +0600 Subject: [PATCH 5/6] docs: tighten the MVI and test docs after review - Limit test names with spaces to local tests, because Android runs such method names only from API level 30 and the minimum is 29. - Note the Main dispatcher exception next to the inject-dispatchers rule in AGENTS.md, where agents look for it. - Cite Animal Sniffer's source for how it treats invokedynamic calls, and split the long Android API check paragraph into a list. - Add imports to the effect-handling sketch, so a copy uses androidx.lifecycle.compose's LocalLifecycleOwner, not the deprecated one in Compose UI. - Say that a state's initial value doesn't come from the reducer. --- AGENTS.md | 2 +- .../io/github/dewfallapp/core/mvi/MviTypes.kt | 4 +-- docs/architecture.md | 25 +++++++++++++++++-- docs/testing.md | 2 +- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 23aa9bb..d853649 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ These apply from Phase 2 on. If Phase 2 refines them, update this section in the - Colors come from MaterialTheme color roles. Never hardcode colors. Every screen must work in light, dark and dynamic color. - Every screen draws edge to edge and handles window insets. Backgrounds may sit under the system bars, but no text or controls sit under the system bars or the display cutout. Android 16 and later give apps that target them no way to opt out. See ADR 0006. - Annotation processors for Kotlin code, such as Hilt's and Room's compilers, run through KSP. The kotlin-kapt plugin doesn't work with AGP's built-in Kotlin, and Google recommends KSP over its com.android.legacy-kapt fallback. Ask first before adding kotlin-kapt or com.android.legacy-kapt. -- Use coroutines and Flow. Never use GlobalScope. Inject dispatchers so tests can replace them. +- Use coroutines and Flow. Never use GlobalScope. Inject dispatchers so tests can replace them. The one exception is viewModelScope's Main dispatcher: ViewModels take no dispatcher, and their tests replace Main with Dispatchers.setMain. - The database uses Room 3, in the androidx.room3 package. Room 2 is deprecated. - Room schemas are exported into core/database/schemas/. Commit the schema files that the build writes, and never edit them by hand. The first public release is the first GitHub release, including a pre-release, published from a version tag. A tag made only to test the release workflow, with no published release, doesn't count, and nightly APKs don't count. Until then, the database stays at version 1, and a schema change needs no migration. From the first public release on, every schema change raises the version and needs a migration and a migration test. Never use destructive migrations. diff --git a/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/MviTypes.kt b/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/MviTypes.kt index 6c29340..6349119 100644 --- a/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/MviTypes.kt +++ b/core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/MviTypes.kt @@ -2,8 +2,8 @@ package io.github.dewfallapp.core.mvi /** * A screen's state. Each screen declares one immutable class for it, usually a data class with val - * properties and read-only collections. Only the screen's reducer makes new ones. See - * docs/architecture.md#the-mvi-contract. + * properties and read-only collections. After the initial state, only the screen's reducer makes + * new ones. See docs/architecture.md#the-mvi-contract. */ interface MviState diff --git a/docs/architecture.md b/docs/architecture.md index 71544bb..cde6d16 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,7 +136,17 @@ Four tools check the code. AGENTS.md lists the commands that run them. **Android Lint.** Lint checks every module, the plain Kotlin/JVM ones through the com.android.lint plugin. Any warning fails the build. Each module checks only its own code. The checks that look up versions or library data on the network are off, so lint gives the same result on every run. AndroidLint.kt in build-logic lists them. The app's manifest suppresses two checks until the tasks that fix them are done: DataExtractionRules (#68) and MissingApplicationIcon (#120). -**The Android API check.** The plain Kotlin/JVM modules compile and run their tests against the full JDK, so a call to a Java API that Android 10 lacks passes there and fails on a phone ([ADR 0006](adr/0006-platform-and-android-versions.md)). Lint can't catch this in these modules. AGP runs lint one module at a time, and lint's API check skips any module that isn't Android ([ApiDetector](https://android.googlesource.com/platform/tools/base/+/refs/heads/mirror-goog-studio-main/lint/libs/lint-checks/src/main/java/com/android/tools/lint/checks/ApiDetector.kt)). So [Animal Sniffer](https://github.com/xvik/gradle-animalsniffer-plugin) checks each of these modules' compiled classes against a [Gummy Bears](https://github.com/open-toast/gummy-bears) signature of the Android API. The signature's API level comes from the minSdk in build-logic, so the check follows it. The signature includes the methods that D8 backports, but not the APIs that core library desugaring adds, because Gummy Bears publishes no such signature for API 29. So the check also flags an API that only desugaring provides. If code needs such an API, check that Android's [desugaring table](https://developer.android.com/studio/write/java11-nio-support-table) lists it, then allow it in configureAndroidApiCheck with Animal Sniffer's ignore or annotation setting, and say why in a comment. It checks the module's own classes, not its dependencies such as NewPipeExtractor. Calls into a dependency aren't checked either, because Animal Sniffer allows every package it finds on the compile classpath, such as kotlinx.coroutines or androidx.lifecycle. So it checks calls to the JDK. Test code never ships, so it isn't checked, and a module with no classes has nothing to check. Kotlin compiles string templates, such as the toString of a data class, to invokedynamic calls through StringConcatFactory, which Android 10 lacks. Animal Sniffer checks only the target method of a lambda's invokedynamic call, so it passes these. That is right, because D8 rewrites them into StringBuilder calls ([StringConcatInstructionDesugaring](https://r8.googlesource.com/r8/+/refs/heads/main/src/main/java/com/android/tools/r8/ir/desugar/stringconcat/StringConcatInstructionDesugaring.java)), and lambdas work on any API level ([Java 8+ language features](https://developer.android.com/studio/write/java8-support)). `./gradlew lint` runs it too. +**The Android API check.** The plain Kotlin/JVM modules compile and run their tests against the full JDK, so a call to a Java API that Android 10 lacks passes there and fails on a phone ([ADR 0006](adr/0006-platform-and-android-versions.md)). Lint can't catch this in these modules. AGP runs lint one module at a time, and lint's API check skips any module that isn't Android ([ApiDetector](https://android.googlesource.com/platform/tools/base/+/refs/heads/mirror-goog-studio-main/lint/libs/lint-checks/src/main/java/com/android/tools/lint/checks/ApiDetector.kt)). So [Animal Sniffer](https://github.com/xvik/gradle-animalsniffer-plugin) checks each of these modules' compiled classes against a [Gummy Bears](https://github.com/open-toast/gummy-bears) signature of the Android API. The signature's API level comes from the minSdk in build-logic, so the check follows it. `./gradlew lint` runs it too. + +The signature includes the methods that D8 backports, but not the APIs that core library desugaring adds, because Gummy Bears publishes no such signature for API 29. So the check also flags an API that only desugaring provides. If code needs such an API, check that Android's [desugaring table](https://developer.android.com/studio/write/java11-nio-support-table) lists it, then allow it in configureAndroidApiCheck with Animal Sniffer's ignore or annotation setting, and say why in a comment. + +What it checks: + +- The module's own classes in src/main. Test code never ships, so it isn't checked, and a module with no classes has nothing to check. +- Calls to the JDK. It doesn't check the module's dependencies, such as NewPipeExtractor, or calls into them, because Animal Sniffer allows every package it finds on the compile classpath, such as kotlinx.coroutines or androidx.lifecycle. +- For an invokedynamic call, only a lambda's target method and interface ([SignatureChecker, Animal Sniffer 1.24](https://github.com/mojohaus/animal-sniffer/blob/animal-sniffer-1.24/animal-sniffer/src/main/java/org/codehaus/mojo/animal_sniffer/SignatureChecker.java#L358-L371)). It never checks the JDK class that sets the call up. + +That last point matters for Kotlin's string templates, such as the toString of a data class. Kotlin compiles them to invokedynamic calls through StringConcatFactory, which Android 10 lacks, and Animal Sniffer passes them. That is right, because D8 rewrites these calls into StringBuilder calls ([StringConcatInstructionDesugaring](https://r8.googlesource.com/r8/+/refs/heads/main/src/main/java/com/android/tools/r8/ir/desugar/stringconcat/StringConcatInstructionDesugaring.java)). Lambdas work on any API level too ([Java 8+ language features](https://developer.android.com/studio/write/java8-support)). ## Network access @@ -156,7 +166,7 @@ Every screen uses MVI, as [ADR 0002](adr/0002-mvi-with-playback-state-outside.md | Type | What it is | |---|---| -| MviState | A screen's state. One immutable class per screen, usually a data class with val properties and read-only collections. Only the screen's reducer makes new ones. | +| MviState | A screen's state. One immutable class per screen, usually a data class with val properties and read-only collections. After the initial state, only the screen's reducer makes new ones. | | MviIntent | Something that happens to the screen: what the user does, or the result of work the ViewModel ran. A screen's intents form a sealed interface. Results are named in the past tense, such as Loaded. | | MviEffect | A one-time instruction to the UI, such as showing a message or opening another screen. A screen's effects form a sealed interface. | | Reducer | A pure function from the old state and an intent to a Next: the new state and any effects, in order. It runs no coroutines, I/O, clock, randomness or shared mutable state. | @@ -202,6 +212,17 @@ Android's [UI events guidance](https://developer.android.com/topic/architecture/ The UI side comes with the first screen in Phase 4. It will look like this sketch. It waits until the screen is started, handles the effect, then reports it. It is keyed by the effect's id, so a recomposition doesn't handle the same effect twice. ```kotlin +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.Lifecycle +// Not androidx.compose.ui.platform.LocalLifecycleOwner, which is deprecated. +import androidx.lifecycle.compose.LocalLifecycleOwner +import io.github.dewfallapp.core.mvi.MviEffect +import io.github.dewfallapp.core.mvi.PendingEffect +import kotlinx.coroutines.flow.first + @Composable fun HandleEffects( pendingEffects: List>, diff --git a/docs/testing.md b/docs/testing.md index dbc86b4..ab1a94e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -65,7 +65,7 @@ Each module writes an HTML report to build/reports/tests/test/index.html in a pl ### Writing tests - Use kotlin.test's annotations and assertions, such as kotlin.test.Test, BeforeTest, AfterTest and assertEquals. They run on JUnit 6 in plain Kotlin/JVM modules and on JUnit 4 in Android modules, so a test reads the same in both. [architecture.md](architecture.md#build-settings) explains why there are two. -- Name each test with backticks, as a sentence about what it checks, such as `` `increment adds one` ``. detekt's naming rule skips test folders. +- Name each local test in src/test/ with backticks, as a sentence about what it checks, such as `` `increment adds one` ``. detekt's naming rule skips test folders. Don't do this in src/androidTest/: Android runs method names with spaces only from API level 30, and the minimum is 29 ([Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html#names-for-test-methods)). - Put tests in the src/test/ folder of the module whose code they test. ### Reducers From 3f6e0da652f26a7130657e9a2175b0bee58cef9d Mon Sep 17 00:00:00 2001 From: Merajul Arefin Date: Tue, 20 Oct 2026 02:43:27 +0600 Subject: [PATCH 6/6] docs: note the cost of effects as state in ADR 0002 - Record that the UI must report each effect as handled and that pending effects are lost on process death, so the ADR's consequences match its amended decision. --- docs/adr/0002-mvi-with-playback-state-outside.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/adr/0002-mvi-with-playback-state-outside.md b/docs/adr/0002-mvi-with-playback-state-outside.md index a0b1de7..47d8f21 100644 --- a/docs/adr/0002-mvi-with-playback-state-outside.md +++ b/docs/adr/0002-mvi-with-playback-state-outside.md @@ -40,6 +40,7 @@ Playback state never goes into screen state. The player lives in a Media3 MediaS - The screen does not redraw every time the playback position changes. - A screen that shows playback reads from two sources: its own MVI state and the player's playback stream. - Every screen needs more code, with State, Intent and Effect types even for simple screens. +- The UI must report each effect as handled. Pending effects are lost on process death. - A single state object can cause extra redraws if it changes too often. That is why playback position stays outside screen state. - Contributors who only know MVVM have more to learn. - There is no official Android MVI library, so Dewfall maintains its own small base classes.