Repository navigation
feat: add the MVI base classes - #136
Merged
Merged
Conversation
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #25.
What changed and why
This adds the MVI base classes in
:core:mvi, sets up unit tests in every module, and writes the MVI contract down.The MVI base (
core/mvi/src/main):MviState,MviIntentandMviEffectmark each screen's types. TheMviprefix keeps them from clashing with Compose'sStateand Android'sIntent.Reduceris a pure function that returnsNext: the new state and any one-time effects.MviViewModelruns every intent through the reducer. It exposesstateandpendingEffects, both StateFlows. Each effect gets a unique id and stays pending until the UI callseffectHandled(id). So a stopped UI or a cancelled handler gets it again. Pending effects survive configuration changes, not process death.dispatchas intents, such asLoaded, so every state change goes through the reducer. Subclasses start work inrunSideEffects.MviViewModellives in the plain Kotlin/JVM module. It compiles against Lifecycle's JVM build,lifecycle-viewmodel-desktop2.11.0. ItsViewModelandviewModelScopeAPI is the same as the Android build's: I compared both with javap.The example (
core/mvi/src/test) is a counter in test code only. It has 4 reducer tests and 5 ViewModel tests: state updates, an async load result coming back through the reducer, an effect staying pending untileffectHandled,effectHandledremoving only that effect, and several effects keeping their order.Unit test setup (
build-logic/.../Testing.kt): tests use kotlin.test everywhere. Plain Kotlin/JVM modules run them on JUnit 6, and Android modules on JUnit 4, which Robolectric and Compose's test rules need. Both get kotlinx-coroutines-test. The JVM setup adds the JUnit Platform launcher explicitly, because Gradle 9 no longer adds it.ADR 0002 is amended in place, as decided: the reducer returns the new state and any one-time effects, intents include work results, and effects are pending UI state until the UI reports them handled, following Android's UI events guidance. Its status stays Accepted. As you decided after the first review, a Consequences line now says that the UI must report each effect as handled, and that pending effects are lost on process death. The roadmap's Phase 2 guide had one sentence saying the reducer returns only a new state, which I changed to match.
Docs: The MVI contract in
docs/architecture.md, Running tests locally indocs/testing.md, and new MVI, test and command lines inAGENTS.md. The Android API check section now says what Animal Sniffer checks, since:core:mviis the first module with classes.Checks run locally:
:core:mvi:test: 9 tests, 0 failures, 0 skipped.animalsnifferMainran on:core:mvi's 7 classes with no violations. A temporary call tojava.util.Optional.isEmpty()(API 33) failed it withUndefined reference: boolean java.util.Optional.isEmpty()againstgummy-bears-api-29. The probe wasn't committed.:core:dataran on JUnit 4, passed, then failed the build when broken. It wasn't committed.:app's release runtime classpath is unchanged (lifecycle 2.9.4, coroutines 1.9.0), because nothing in the app depends on:core:mviyet.New dependencies
No new Gradle plugins. Licenses are taken from each POM.
-desktop)lifecycle-viewmodel-androidonce a feature module uses:core:mvi, replacing 2.9.4.-jvm, pluskotlinx-coroutines-bom)-jvm)Test libraries never ship, so they fall under the build tool rule in AGENTS.md.
Checklist
Reviewer
Final verdict: Ready
:core:mvi, Android modules' unit tests run on kotlinx-coroutines 1.11.0, through kotlinx-coroutines-test, while the APK ships 1.9.0. The maintainer accepted this until a feature module depends on:core:mvi.Failing-test proof
Done in #137, a ready pull request from this branch with one deliberately failing test added to CounterReducerTest:
:core:mvi:test, with 10 tests and 1 failure:CounterReducerTest > deliberately failing test(). Formatting, detekt, lint, the Android API check and the debug build passed, and the reports artifact was uploaded.This repeats Phase 2's "Done when" with a real failing unit test, as the comment on #25 asked. #126 proved it earlier for a failing detekt check.
🤖 Generated with Claude Code