diff --git a/build.gradle.kts b/build.gradle.kts index 61c5f6f6..a27a5868 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.ktlint) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5ba23ce..b449fd4d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -95,6 +95,7 @@ javax-inject = { group = "javax.inject", name = "javax.inject", version.ref = "j orbit-core = { group = "org.orbit-mvi", name = "orbit-core", version.ref = "orbit" } orbit-compose = { group = "org.orbit-mvi", name = "orbit-compose", version.ref = "orbit" } orbit-viewmodel = { group = "org.orbit-mvi", name = "orbit-viewmodel", version.ref = "orbit" } +orbit-test = { group = "org.orbit-mvi", name = "orbit-test", version.ref = "orbit" } ## retrofit retrofit-bom = { group = "com.squareup.retrofit2", name = "retrofit-bom", version.ref = "retrofit" } @@ -110,6 +111,7 @@ okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-i junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +kotlin-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } ## Other material = { group = "com.google.android.material", name = "material", version.ref = "material" } @@ -171,6 +173,7 @@ android-library = { id = "com.android.library", version.ref = "androidGradlePlug kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin"} compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index 5d6b9a1e..f5a0d017 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.bitnagil.android.library) alias(libs.plugins.bitnagil.android.compose.library) + alias(libs.plugins.kotlin.parcelize) } android { @@ -12,4 +13,8 @@ dependencies { implementation(libs.bundles.androidx.core) implementation(libs.bundles.orbit) + + testImplementation(libs.junit) + testImplementation(libs.kotlin.coroutines.test) + testImplementation(libs.orbit.test) } diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviIntent.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviIntent.kt new file mode 100644 index 00000000..e7ce3f56 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviIntent.kt @@ -0,0 +1,3 @@ +package com.threegap.bitnagil.presentation.common.mviviewmodel + +interface MviIntent diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviSideEffect.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviSideEffect.kt new file mode 100644 index 00000000..c5eb002e --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviSideEffect.kt @@ -0,0 +1,3 @@ +package com.threegap.bitnagil.presentation.common.mviviewmodel + +interface MviSideEffect diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviState.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviState.kt new file mode 100644 index 00000000..da43d869 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviState.kt @@ -0,0 +1,5 @@ +package com.threegap.bitnagil.presentation.common.mviviewmodel + +import android.os.Parcelable + +interface MviState : Parcelable diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviViewModel.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviViewModel.kt new file mode 100644 index 00000000..1e4ad630 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviViewModel.kt @@ -0,0 +1,38 @@ +package com.threegap.bitnagil.presentation.common.mviviewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.SimpleSyntax +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.postSideEffect +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container + +abstract class MviViewModel( + initState: STATE, + savedStateHandle: SavedStateHandle, +) : ContainerHost, ViewModel() { + override val container = container(initialState = initState, savedStateHandle = savedStateHandle) + + val stateFlow: StateFlow get() = container.stateFlow + val sideEffectFlow: Flow get() = container.sideEffectFlow + + protected suspend fun SimpleSyntax.sendSideEffect(sideEffect: SIDE_EFFECT) = postSideEffect(sideEffect) + + protected abstract suspend fun SimpleSyntax.reduceState( + intent: INTENT, + state: STATE, + ): STATE? + + fun sendIntent(intent: INTENT) = + intent { + val newState = reduceState(intent, state) + + newState?.let { + reduce { newState } + } + } +} diff --git a/presentation/src/test/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviViewModelTest.kt b/presentation/src/test/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviViewModelTest.kt new file mode 100644 index 00000000..912711c7 --- /dev/null +++ b/presentation/src/test/java/com/threegap/bitnagil/presentation/common/mviviewmodel/MviViewModelTest.kt @@ -0,0 +1,92 @@ +package com.threegap.bitnagil.presentation.common.mviviewmodel + +import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.parcelize.Parcelize +import org.junit.Before +import org.junit.Test +import org.orbitmvi.orbit.syntax.simple.SimpleSyntax +import org.orbitmvi.orbit.test.test + +@ExperimentalCoroutinesApi +class MviViewModelTest { + private lateinit var sampleMviViewModel: SampleMviViewModel + + @Before + fun setUp() { + sampleMviViewModel = SampleMviViewModel(initState = SampleState(), savedStateHandle = SavedStateHandle()) + } + + @Test + fun `state는 호출 순서대로 갱신되어야 한다`() = + runTest { + sampleMviViewModel.test(testScope = this) { + containerHost.sendIntent(SampleIntent.Increase(number = 1)) + containerHost.sendIntent(SampleIntent.Decrease(number = 2)) + containerHost.sendIntent(SampleIntent.Increase(number = 3)) + + expectState { SampleState() } + expectState { SampleState(count = 1) } + expectState { SampleState(count = -1) } + expectState { SampleState(count = 2) } + } + } + + @Test + fun `state와 sideEffect는 호출 순서대로 갱신되어야 한다`() = + runTest { + sampleMviViewModel.test(testScope = this) { + containerHost.sendIntent(SampleIntent.Increase(number = 1)) + containerHost.sendIntent(SampleIntent.Clear) + containerHost.sendIntent(SampleIntent.Decrease(number = 2)) + containerHost.sendIntent(SampleIntent.Increase(number = 3)) + + expectState { SampleState() } + expectState { SampleState(count = 1) } + expectSideEffect(SampleSideEffect.ShowToast("Clear")) + expectState { SampleState() } + expectState { SampleState(count = -2) } + expectState { SampleState(count = 1) } + } + } + + // only for test + private class SampleMviViewModel( + initState: SampleState, + savedStateHandle: SavedStateHandle, + ) : MviViewModel(initState, savedStateHandle) { + override suspend fun SimpleSyntax.reduceState( + intent: SampleIntent, + state: SampleState, + ): SampleState? { + val newState = + when (intent) { + is SampleIntent.Decrease -> state.copy(count = state.count - intent.number) + is SampleIntent.Increase -> state.copy(count = state.count + intent.number) + SampleIntent.Clear -> { + sendSideEffect(sideEffect = SampleSideEffect.ShowToast("Clear")) + state.copy(count = 0) + } + } + return newState + } + } + + @Parcelize + private data class SampleState( + val count: Int = 0, + ) : MviState + + private sealed class SampleSideEffect : MviSideEffect { + data class ShowToast(val message: String) : SampleSideEffect() + } + + private sealed class SampleIntent : MviIntent { + data class Increase(val number: Int) : SampleIntent() + + data class Decrease(val number: Int) : SampleIntent() + + object Clear : SampleIntent() + } +}