From 84bb15e5dbeddce8f6a6b496d667723772fb29c5 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 7 Nov 2022 20:53:35 +0000 Subject: [PATCH 1/2] porting profile to reducer --- .../kotlin/fake/FakeChatEngine.kt | 6 +- .../main/kotlin/app/dapk/st/core/JobBag.kt | 4 + features/directory/build.gradle | 2 - .../kotlin/app/dapk/st/home/HomeModule.kt | 6 +- .../kotlin/app/dapk/st/home/HomeViewModel.kt | 9 +- .../kotlin/app/dapk/st/home/MainActivity.kt | 2 +- features/profile/build.gradle | 11 +- .../app/dapk/st/profile/ProfileModule.kt | 9 +- .../app/dapk/st/profile/ProfileScreen.kt | 28 ++-- .../app/dapk/st/profile/ProfileViewModel.kt | 142 ------------------ .../dapk/st/profile/state/ProfileAction.kt | 17 +++ .../dapk/st/profile/state/ProfileReducer.kt | 87 +++++++++++ .../st/profile/{ => state}/ProfileState.kt | 15 +- .../dapk/st/profile/state/ProfileUseCase.kt | 30 ++++ .../st/profile/state/ProfileReducerTest.kt | 121 +++++++++++++++ 15 files changed, 307 insertions(+), 182 deletions(-) delete mode 100644 features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt create mode 100644 features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileAction.kt create mode 100644 features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileReducer.kt rename features/profile/src/main/kotlin/app/dapk/st/profile/{ => state}/ProfileState.kt (71%) create mode 100644 features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileUseCase.kt create mode 100644 features/profile/src/test/kotlin/app/dapk/st/profile/state/ProfileReducerTest.kt diff --git a/chat-engine/src/testFixtures/kotlin/fake/FakeChatEngine.kt b/chat-engine/src/testFixtures/kotlin/fake/FakeChatEngine.kt index bf5ff6f..9452a2f 100644 --- a/chat-engine/src/testFixtures/kotlin/fake/FakeChatEngine.kt +++ b/chat-engine/src/testFixtures/kotlin/fake/FakeChatEngine.kt @@ -12,13 +12,9 @@ import java.io.InputStream class FakeChatEngine : ChatEngine by mockk() { fun givenMessages(roomId: RoomId, disableReadReceipts: Boolean) = every { messages(roomId, disableReadReceipts) }.delegateReturn() - fun givenDirectory() = every { directory() }.delegateReturn() - fun givenImportKeys(inputStream: InputStream, passphrase: String) = coEvery { inputStream.importRoomKeys(passphrase) }.delegateReturn() - fun givenNotificationsInvites() = every { notificationsInvites() }.delegateEmit() - fun givenNotificationsMessages() = every { notificationsMessages() }.delegateEmit() - + fun givenInvites() = every { invites() }.delegateEmit() } \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/JobBag.kt b/core/src/main/kotlin/app/dapk/st/core/JobBag.kt index 1e22518..d74b214 100644 --- a/core/src/main/kotlin/app/dapk/st/core/JobBag.kt +++ b/core/src/main/kotlin/app/dapk/st/core/JobBag.kt @@ -25,4 +25,8 @@ class JobBag { jobs.remove(key.java.canonicalName)?.cancel() } + fun cancelAll() { + jobs.values.forEach { it.cancel() } + } + } \ No newline at end of file diff --git a/features/directory/build.gradle b/features/directory/build.gradle index d1d22ef..a3d7490 100644 --- a/features/directory/build.gradle +++ b/features/directory/build.gradle @@ -3,7 +3,6 @@ applyAndroidComposeLibraryModule(project) dependencies { implementation project(":chat-engine") implementation project(":domains:android:compose-core") - implementation project(":domains:android:viewmodel") implementation project(":domains:state") implementation project(":features:messenger") implementation project(":core") @@ -16,7 +15,6 @@ dependencies { androidImportFixturesWorkaround(project, project(":core")) androidImportFixturesWorkaround(project, project(":domains:state")) androidImportFixturesWorkaround(project, project(":domains:store")) - androidImportFixturesWorkaround(project, project(":domains:android:viewmodel")) androidImportFixturesWorkaround(project, project(":domains:android:stub")) androidImportFixturesWorkaround(project, project(":chat-engine")) } \ No newline at end of file diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt index 12bfd88..9133a05 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt @@ -5,7 +5,7 @@ import app.dapk.st.directory.state.DirectoryState import app.dapk.st.domain.StoreModule import app.dapk.st.engine.ChatEngine import app.dapk.st.login.LoginViewModel -import app.dapk.st.profile.ProfileViewModel +import app.dapk.st.profile.state.ProfileState class HomeModule( private val chatEngine: ChatEngine, @@ -13,13 +13,13 @@ class HomeModule( val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase, ) : ProvidableModule { - internal fun homeViewModel(directory: DirectoryState, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel { + internal fun homeViewModel(directory: DirectoryState, login: LoginViewModel, profile: ProfileState): HomeViewModel { return HomeViewModel( chatEngine, storeModule.credentialsStore(), directory, login, - profileViewModel, + profile, storeModule.cacheCleaner(), betaVersionUpgradeUseCase, ) diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt index 7aa055f..f4545d7 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt @@ -10,7 +10,8 @@ import app.dapk.st.home.HomeScreenState.* import app.dapk.st.login.LoginViewModel import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.common.isSignedIn -import app.dapk.st.profile.ProfileViewModel +import app.dapk.st.profile.state.ProfileAction +import app.dapk.st.profile.state.ProfileState import app.dapk.st.viewmodel.DapkViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -24,7 +25,7 @@ internal class HomeViewModel( private val credentialsProvider: CredentialsStore, private val directoryState: DirectoryState, private val loginViewModel: LoginViewModel, - private val profileViewModel: ProfileViewModel, + private val profileState: ProfileState, private val cacheCleaner: StoreCleaner, private val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase, ) : DapkViewModel( @@ -35,7 +36,7 @@ internal class HomeViewModel( fun directory() = directoryState fun login() = loginViewModel - fun profile() = profileViewModel + fun profile() = profileState fun start() { viewModelScope.launch { @@ -125,7 +126,7 @@ internal class HomeViewModel( Page.Profile -> { directoryState.dispatch(ComponentLifecycle.OnGone) - profileViewModel.reset() + profileState.dispatch(ProfileAction.Reset) } } } diff --git a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt index f8d86a3..7ac8bc8 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt @@ -25,7 +25,7 @@ class MainActivity : DapkActivity() { private val directoryViewModel by state { module().directoryState() } private val loginViewModel by viewModel { module().loginViewModel() } - private val profileViewModel by viewModel { module().profileViewModel() } + private val profileViewModel by state { module().profileState() } private val homeViewModel by viewModel { module().homeViewModel(directoryViewModel, loginViewModel, profileViewModel) } override fun onCreate(savedInstanceState: Bundle?) { diff --git a/features/profile/build.gradle b/features/profile/build.gradle index 89e50f1..f3697f1 100644 --- a/features/profile/build.gradle +++ b/features/profile/build.gradle @@ -4,8 +4,17 @@ dependencies { implementation project(":chat-engine") implementation project(":features:settings") implementation project(':domains:store') + implementation project(':domains:state') implementation project(":domains:android:compose-core") - implementation project(":domains:android:viewmodel") implementation project(":design-library") implementation project(":core") + + kotlinTest(it) + + androidImportFixturesWorkaround(project, project(":matrix:common")) + androidImportFixturesWorkaround(project, project(":core")) + androidImportFixturesWorkaround(project, project(":domains:state")) + androidImportFixturesWorkaround(project, project(":domains:store")) + androidImportFixturesWorkaround(project, project(":domains:android:stub")) + androidImportFixturesWorkaround(project, project(":chat-engine")) } \ No newline at end of file diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt index 4766144..2b0037f 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt @@ -1,16 +1,21 @@ package app.dapk.st.profile +import app.dapk.st.core.JobBag import app.dapk.st.core.ProvidableModule +import app.dapk.st.core.createStateViewModel import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.engine.ChatEngine +import app.dapk.st.profile.state.ProfileState +import app.dapk.st.profile.state.ProfileUseCase +import app.dapk.st.profile.state.profileReducer class ProfileModule( private val chatEngine: ChatEngine, private val errorTracker: ErrorTracker, ) : ProvidableModule { - fun profileViewModel(): ProfileViewModel { - return ProfileViewModel(chatEngine, errorTracker) + fun profileState(): ProfileState { + return createStateViewModel { profileReducer(chatEngine, errorTracker, ProfileUseCase(chatEngine, errorTracker), JobBag()) } } } \ No newline at end of file diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt index d08d2b4..6b2fbdc 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt @@ -20,18 +20,22 @@ import androidx.compose.ui.unit.dp import app.dapk.st.core.Lce import app.dapk.st.core.LifecycleEffect import app.dapk.st.core.components.CenteredLoading +import app.dapk.st.core.page.PageAction import app.dapk.st.design.components.* import app.dapk.st.engine.RoomInvite import app.dapk.st.engine.RoomInvite.InviteMeta +import app.dapk.st.profile.state.Page +import app.dapk.st.profile.state.ProfileAction +import app.dapk.st.profile.state.ProfileState import app.dapk.st.settings.SettingsActivity @Composable -fun ProfileScreen(viewModel: ProfileViewModel, onTopLevelBack: () -> Unit) { +fun ProfileScreen(viewModel: ProfileState, onTopLevelBack: () -> Unit) { viewModel.ObserveEvents() LifecycleEffect( - onStart = { viewModel.start() }, - onStop = { viewModel.stop() } + onStart = { viewModel.dispatch(ProfileAction.ComponentLifecycle.Visible) }, + onStop = { viewModel.dispatch(ProfileAction.ComponentLifecycle.Gone) } ) val context = LocalContext.current @@ -39,11 +43,11 @@ fun ProfileScreen(viewModel: ProfileViewModel, onTopLevelBack: () -> Unit) { val onNavigate: (SpiderPage?) -> Unit = { when (it) { null -> onTopLevelBack() - else -> viewModel.goTo(it) + else -> viewModel.dispatch(PageAction.GoTo(it)) } } - Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) { + Spider(currentPage = viewModel.current.state1.page, onNavigate = onNavigate) { item(Page.Routes.profile) { ProfilePage(context, viewModel, it) } @@ -54,7 +58,7 @@ fun ProfileScreen(viewModel: ProfileViewModel, onTopLevelBack: () -> Unit) { } @Composable -private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile: Page.Profile) { +private fun ProfilePage(context: Context, viewModel: ProfileState, profile: Page.Profile) { Box( modifier = Modifier .fillMaxWidth() @@ -67,7 +71,7 @@ private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile: when (val state = profile.content) { is Lce.Loading -> CenteredLoading() - is Lce.Error -> GenericError { viewModel.start() } + is Lce.Error -> GenericError { viewModel.dispatch(ProfileAction.ComponentLifecycle.Visible) } is Lce.Content -> { val configuration = LocalConfiguration.current val content = state.value @@ -111,7 +115,7 @@ private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile: TextRow( title = "Invitations", content = "${content.invitationsCount} pending", - onClick = { viewModel.goToInvitations() } + onClick = { viewModel.dispatch(ProfileAction.GoToInvitations) } ) } } @@ -119,7 +123,7 @@ private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile: } @Composable -private fun SpiderItemScope.Invitations(viewModel: ProfileViewModel, invitations: Page.Invitations) { +private fun SpiderItemScope.Invitations(viewModel: ProfileState, invitations: Page.Invitations) { when (val state = invitations.content) { is Lce.Loading -> CenteredLoading() is Lce.Content -> { @@ -133,11 +137,11 @@ private fun SpiderItemScope.Invitations(viewModel: ProfileViewModel, invitations TextRow(title = text, includeDivider = false) { Spacer(modifier = Modifier.height(4.dp)) Row { - Button(modifier = Modifier.weight(1f), onClick = { viewModel.rejectRoomInvite(it.roomId) }) { + Button(modifier = Modifier.weight(1f), onClick = { viewModel.dispatch(ProfileAction.RejectRoomInvite(it.roomId)) }) { Text("Reject".uppercase()) } Spacer(modifier = Modifier.fillMaxWidth(0.1f)) - Button(modifier = Modifier.weight(1f), onClick = { viewModel.acceptRoomInvite(it.roomId) }) { + Button(modifier = Modifier.weight(1f), onClick = { viewModel.dispatch(ProfileAction.AcceptRoomInvite(it.roomId)) }) { Text("Accept".uppercase()) } } @@ -154,7 +158,7 @@ private fun SpiderItemScope.Invitations(viewModel: ProfileViewModel, invitations private fun RoomInvite.inviterName() = this.from.displayName?.let { "$it (${this.from.id.value})" } ?: this.from.id.value @Composable -private fun ProfileViewModel.ObserveEvents() { +private fun ProfileState.ObserveEvents() { // StartObserving { // this@ObserveEvents.events.launch { // when (it) { diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt deleted file mode 100644 index ac5806c..0000000 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt +++ /dev/null @@ -1,142 +0,0 @@ -package app.dapk.st.profile - -import android.util.Log -import androidx.lifecycle.viewModelScope -import app.dapk.st.core.Lce -import app.dapk.st.core.extensions.ErrorTracker -import app.dapk.st.design.components.SpiderPage -import app.dapk.st.engine.ChatEngine -import app.dapk.st.matrix.common.RoomId -import app.dapk.st.viewmodel.DapkViewModel -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch - -class ProfileViewModel( - private val chatEngine: ChatEngine, - private val errorTracker: ErrorTracker, -) : DapkViewModel( - ProfileScreenState(SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false)) -) { - - private var currentPageJob: Job? = null - - fun start() { - goToProfile() - } - - private fun goToProfile() { - combine( - flow { - val result = runCatching { chatEngine.me(forceRefresh = true) } - .onFailure { errorTracker.track(it, "Loading profile") } - emit(result) - }, - chatEngine.invites(), - transform = { me, invites -> me to invites } - ) - .onEach { (me, invites) -> - updatePageState { - when (me.isSuccess) { - true -> copy(content = Lce.Content(Page.Profile.Content(me.getOrThrow(), invites.size))) - false -> copy(content = Lce.Error(me.exceptionOrNull()!!)) - } - } - } - .launchPageJob() - } - - - fun goToInvitations() { - updateState { copy(page = SpiderPage(Page.Routes.invitation, "Invitations", Page.Routes.profile, Page.Invitations(Lce.Loading()))) } - - chatEngine.invites() - .onEach { - updatePageState { - copy(content = Lce.Content(it)) - } - } - .launchPageJob() - } - - fun goTo(page: SpiderPage) { - currentPageJob?.cancel() - updateState { copy(page = page) } - when (page.state) { - is Page.Invitations -> goToInvitations() - is Page.Profile -> goToProfile() - } - } - - private fun Flow.launchPageJob() { - currentPageJob?.cancel() - currentPageJob = this.launchIn(viewModelScope) - } - - fun updateDisplayName() { - // TODO - } - - fun updateAvatar() { - // TODO - } - - fun acceptRoomInvite(roomId: RoomId) { - launchCatching { chatEngine.joinRoom(roomId) }.fold( - onError = {} - ) - } - - fun rejectRoomInvite(roomId: RoomId) { - launchCatching { chatEngine.rejectJoinRoom(roomId) }.fold( - onError = { - Log.e("!!!", it.message, it) - } - ) - } - - @Suppress("UNCHECKED_CAST") - private inline fun updatePageState(crossinline block: S.() -> S) { - val page = state.page - val currentState = page.state - require(currentState is S) - updateState { copy(page = (page as SpiderPage).copy(state = block(page.state))) } - } - - fun reset() { - when (state.page.state) { - is Page.Invitations -> updateState { - ProfileScreenState( - SpiderPage( - Page.Routes.profile, - "Profile", - null, - Page.Profile(Lce.Loading()), - hasToolbar = false - ) - ) - } - - is Page.Profile -> { - // do nothing - } - } - } - - fun stop() { - currentPageJob?.cancel() - } - -} - -fun DapkViewModel.launchCatching(block: suspend () -> T): LaunchCatching { - return object : LaunchCatching { - override fun fold(onSuccess: (T) -> Unit, onError: (Throwable) -> Unit) { - viewModelScope.launch { runCatching { block() }.fold(onSuccess, onError) } - } - } -} - -interface LaunchCatching { - fun fold(onSuccess: (T) -> Unit = {}, onError: (Throwable) -> Unit = {}) -} \ No newline at end of file diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileAction.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileAction.kt new file mode 100644 index 0000000..898b235 --- /dev/null +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileAction.kt @@ -0,0 +1,17 @@ +package app.dapk.st.profile.state + +import app.dapk.st.matrix.common.RoomId +import app.dapk.state.Action + +sealed interface ProfileAction : Action { + + sealed interface ComponentLifecycle : ProfileAction { + object Visible : ComponentLifecycle + object Gone : ComponentLifecycle + } + + object GoToInvitations : ProfileAction + data class AcceptRoomInvite(val roomId: RoomId) : ProfileAction + data class RejectRoomInvite(val roomId: RoomId) : ProfileAction + object Reset : ProfileAction +} \ No newline at end of file diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileReducer.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileReducer.kt new file mode 100644 index 0000000..8d01ee7 --- /dev/null +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileReducer.kt @@ -0,0 +1,87 @@ +package app.dapk.st.profile.state + +import app.dapk.st.core.JobBag +import app.dapk.st.core.Lce +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.core.page.PageAction +import app.dapk.st.core.page.PageStateChange +import app.dapk.st.core.page.createPageReducer +import app.dapk.st.core.page.withPageContext +import app.dapk.st.design.components.SpiderPage +import app.dapk.st.engine.ChatEngine +import app.dapk.state.async +import app.dapk.state.createReducer +import app.dapk.state.sideEffect +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +fun profileReducer( + chatEngine: ChatEngine, + errorTracker: ErrorTracker, + profileUseCase: ProfileUseCase, + jobBag: JobBag, +) = createPageReducer( + initialPage = SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false), + factory = { + createReducer( + initialState = Unit, + + async(ProfileAction.ComponentLifecycle::class) { + when (it) { + ProfileAction.ComponentLifecycle.Visible -> { + jobBag.replace(Page.Profile::class, profileUseCase.content().onEach { content -> + withPageContext { + pageDispatch(PageStateChange.UpdatePage(it.copy(content = content))) + } + }.launchIn(coroutineScope)) + } + + ProfileAction.ComponentLifecycle.Gone -> jobBag.cancelAll() + } + }, + + async(ProfileAction.GoToInvitations::class) { + dispatch(PageAction.GoTo(SpiderPage(Page.Routes.invitation, "Invitations", Page.Routes.profile, Page.Invitations(Lce.Loading())))) + + jobBag.replace(Page.Invitations::class, chatEngine.invites() + .onEach { invitations -> + withPageContext { + pageDispatch(PageStateChange.UpdatePage(it.copy(content = Lce.Content(invitations)))) + } + } + .launchIn(coroutineScope)) + + }, + + async(ProfileAction.Reset::class) { + when (rawPage().state) { + is Page.Invitations -> { + dispatch(PageAction.GoTo(SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false))) + } + + is Page.Profile -> { + // do nothing + } + } + }, + + sideEffect(ProfileAction.AcceptRoomInvite::class) { action, _ -> + kotlin.runCatching { chatEngine.joinRoom(action.roomId) }.fold( + onFailure = { errorTracker.track(it) }, + onSuccess = {} + ) + }, + + sideEffect(ProfileAction.RejectRoomInvite::class) { action, _ -> + kotlin.runCatching { chatEngine.rejectJoinRoom(action.roomId) }.fold( + onFailure = { errorTracker.track(it) }, + onSuccess = {} + ) + }, + + sideEffect(PageStateChange.ChangePage::class) { action, _ -> + jobBag.cancel(action.previous.state::class) + }, + ) + } +) diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileState.kt similarity index 71% rename from features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt rename to features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileState.kt index b7754df..7490b49 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileState.kt @@ -1,14 +1,14 @@ -package app.dapk.st.profile +package app.dapk.st.profile.state import app.dapk.st.core.Lce +import app.dapk.st.core.State +import app.dapk.st.core.page.PageContainer import app.dapk.st.design.components.Route -import app.dapk.st.design.components.SpiderPage import app.dapk.st.engine.Me import app.dapk.st.engine.RoomInvite +import app.dapk.state.Combined2 -data class ProfileScreenState( - val page: SpiderPage, -) +typealias ProfileState = State, Unit>, Unit> sealed interface Page { data class Profile(val content: Lce) : Page { @@ -25,8 +25,3 @@ sealed interface Page { val invitation = Route("Invitations") } } - -sealed interface ProfileEvent { - -} - diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileUseCase.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileUseCase.kt new file mode 100644 index 0000000..001ef15 --- /dev/null +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileUseCase.kt @@ -0,0 +1,30 @@ +package app.dapk.st.profile.state + +import app.dapk.st.core.Lce +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.engine.ChatEngine +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map + +class ProfileUseCase( + private val chatEngine: ChatEngine, + private val errorTracker: ErrorTracker, +) { + + fun content(): Flow> { + val flow = flow { + val result = runCatching { chatEngine.me(forceRefresh = true) } + .onFailure { errorTracker.track(it, "Loading profile") } + emit(result) + } + val combine = combine(flow, chatEngine.invites(), transform = { me, invites -> me to invites }) + return combine.map { (me, invites) -> + when (me.isSuccess) { + true -> Lce.Content(Page.Profile.Content(me.getOrThrow(), invites.size)) + false -> Lce.Error(me.exceptionOrNull()!!) + } + } + } +} \ No newline at end of file diff --git a/features/profile/src/test/kotlin/app/dapk/st/profile/state/ProfileReducerTest.kt b/features/profile/src/test/kotlin/app/dapk/st/profile/state/ProfileReducerTest.kt new file mode 100644 index 0000000..68fb60c --- /dev/null +++ b/features/profile/src/test/kotlin/app/dapk/st/profile/state/ProfileReducerTest.kt @@ -0,0 +1,121 @@ +package app.dapk.st.profile.state + +import app.dapk.st.core.Lce +import app.dapk.st.core.page.PageAction +import app.dapk.st.core.page.PageContainer +import app.dapk.st.core.page.PageStateChange +import app.dapk.st.design.components.SpiderPage +import app.dapk.st.engine.Me +import app.dapk.st.matrix.common.HomeServerUrl +import app.dapk.state.Combined2 +import fake.FakeChatEngine +import fake.FakeErrorTracker +import fake.FakeJobBag +import fixture.aRoomId +import fixture.aRoomInvite +import fixture.aUserId +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import test.assertOnlyDispatches +import test.delegateEmit +import test.testReducer + +private val INITIAL_PROFILE_PAGE = SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false) +private val INITIAL_INVITATION_PAGE = SpiderPage(Page.Routes.invitation, "Invitations", Page.Routes.profile, Page.Invitations(Lce.Loading()), hasToolbar = true) +private val A_ROOM_ID = aRoomId() +private val A_PROFILE_CONTENT = Page.Profile.Content(Me(aUserId(), null, null, HomeServerUrl("ignored")), invitationsCount = 4) + +class ProfileReducerTest { + + private val fakeChatEngine = FakeChatEngine() + private val fakeErrorTracker = FakeErrorTracker() + private val fakeProfileUseCase = FakeProfileUseCase() + private val fakeJobBag = FakeJobBag() + + private val runReducerTest = testReducer { _: (Unit) -> Unit -> + profileReducer( + fakeChatEngine, + fakeErrorTracker, + fakeProfileUseCase.instance, + fakeJobBag.instance, + ) + } + + @Test + fun `initial state is empty loading`() = runReducerTest { + assertInitialState(pageState(SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false))) + } + + @Test + fun `given on Profile page, when Reset, then does nothing`() = runReducerTest { + reduce(ProfileAction.Reset) + + assertNoChanges() + } + + @Test + fun `when Visible, then updates Profile page content`() = runReducerTest { + fakeJobBag.instance.expect { it.replace(Page.Profile::class, any()) } + fakeProfileUseCase.givenContent().emits(Lce.Content(A_PROFILE_CONTENT)) + + reduce(ProfileAction.ComponentLifecycle.Visible) + + assertOnlyDispatches( + PageStateChange.UpdatePage(INITIAL_PROFILE_PAGE.state.copy(Lce.Content(A_PROFILE_CONTENT))), + ) + } + + @Test + fun `when GoToInvitations, then goes to Invitations page and updates content`() = runReducerTest { + fakeJobBag.instance.expect { it.replace(Page.Invitations::class, any()) } + val goToInvitations = PageAction.GoTo(INITIAL_INVITATION_PAGE) + actionSideEffect(goToInvitations) { pageState(goToInvitations.page) } + val content = listOf(aRoomInvite()) + fakeChatEngine.givenInvites().emits(content) + + reduce(ProfileAction.GoToInvitations) + + assertOnlyDispatches( + PageAction.GoTo(INITIAL_INVITATION_PAGE), + PageStateChange.UpdatePage(INITIAL_INVITATION_PAGE.state.copy(Lce.Content(content))), + ) + } + + @Test + fun `given on Invitation page, when Reset, then goes to Profile page`() = runReducerTest { + setState(pageState(INITIAL_INVITATION_PAGE)) + + reduce(ProfileAction.Reset) + + assertOnlyDispatches(PageAction.GoTo(INITIAL_PROFILE_PAGE)) + } + + @Test + fun `when RejectRoomInvite, then rejects room`() = runReducerTest { + fakeChatEngine.expect { it.rejectJoinRoom(A_ROOM_ID) } + + reduce(ProfileAction.RejectRoomInvite(A_ROOM_ID)) + } + + @Test + fun `when AcceptRoomInvite, then joins room`() = runReducerTest { + fakeChatEngine.expect { it.joinRoom(A_ROOM_ID) } + + reduce(ProfileAction.AcceptRoomInvite(A_ROOM_ID)) + } + + @Test + fun `when ChangePage, then cancels any previous page jobs`() = runReducerTest { + fakeJobBag.instance.expect { it.cancel(Page.Invitations::class) } + + reduce(PageStateChange.ChangePage(INITIAL_INVITATION_PAGE, INITIAL_PROFILE_PAGE)) + } +} + +private fun

pageState(page: SpiderPage) = Combined2(PageContainer(page), Unit) + +class FakeProfileUseCase { + val instance = mockk() + fun givenContent() = every { instance.content() }.delegateEmit() +} From aa19346aed09de97388548079c4ea7487434ae4a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 7 Nov 2022 21:13:52 +0000 Subject: [PATCH 2/2] create tests for profile use case --- .../kotlin/fake/FakeChatEngine.kt | 1 + .../dapk/st/profile/state/ProfileUseCase.kt | 19 +++++--- .../st/profile/state/ProfileUseCaseTest.kt | 45 +++++++++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 features/profile/src/test/kotlin/app/dapk/st/profile/state/ProfileUseCaseTest.kt diff --git a/chat-engine/src/testFixtures/kotlin/fake/FakeChatEngine.kt b/chat-engine/src/testFixtures/kotlin/fake/FakeChatEngine.kt index 9452a2f..9486318 100644 --- a/chat-engine/src/testFixtures/kotlin/fake/FakeChatEngine.kt +++ b/chat-engine/src/testFixtures/kotlin/fake/FakeChatEngine.kt @@ -17,4 +17,5 @@ class FakeChatEngine : ChatEngine by mockk() { fun givenNotificationsInvites() = every { notificationsInvites() }.delegateEmit() fun givenNotificationsMessages() = every { notificationsMessages() }.delegateEmit() fun givenInvites() = every { invites() }.delegateEmit() + fun givenMe(forceRefresh: Boolean) = coEvery { me(forceRefresh) }.delegateReturn() } \ No newline at end of file diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileUseCase.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileUseCase.kt index 001ef15..ba533ed 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileUseCase.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileUseCase.kt @@ -3,6 +3,7 @@ package app.dapk.st.profile.state import app.dapk.st.core.Lce import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.engine.ChatEngine +import app.dapk.st.engine.Me import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow @@ -13,18 +14,22 @@ class ProfileUseCase( private val errorTracker: ErrorTracker, ) { + private var meCache: Me? = null + fun content(): Flow> { - val flow = flow { - val result = runCatching { chatEngine.me(forceRefresh = true) } - .onFailure { errorTracker.track(it, "Loading profile") } - emit(result) - } - val combine = combine(flow, chatEngine.invites(), transform = { me, invites -> me to invites }) - return combine.map { (me, invites) -> + return combine(fetchMe(), chatEngine.invites(), transform = { me, invites -> me to invites }).map { (me, invites) -> when (me.isSuccess) { true -> Lce.Content(Page.Profile.Content(me.getOrThrow(), invites.size)) false -> Lce.Error(me.exceptionOrNull()!!) } } } + + private fun fetchMe() = flow { + meCache?.let { emit(Result.success(it)) } + val result = runCatching { chatEngine.me(forceRefresh = true) } + .onFailure { errorTracker.track(it, "Loading profile") } + .onSuccess { meCache = it } + emit(result) + } } \ No newline at end of file diff --git a/features/profile/src/test/kotlin/app/dapk/st/profile/state/ProfileUseCaseTest.kt b/features/profile/src/test/kotlin/app/dapk/st/profile/state/ProfileUseCaseTest.kt new file mode 100644 index 0000000..5b583ba --- /dev/null +++ b/features/profile/src/test/kotlin/app/dapk/st/profile/state/ProfileUseCaseTest.kt @@ -0,0 +1,45 @@ +package app.dapk.st.profile.state + +import app.dapk.st.core.Lce +import app.dapk.st.engine.Me +import app.dapk.st.matrix.common.HomeServerUrl +import fake.FakeChatEngine +import fake.FakeErrorTracker +import fixture.aRoomInvite +import fixture.aUserId +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val A_ME = Me(aUserId(), null, null, HomeServerUrl("ignored")) +private val AN_INVITES_LIST = listOf(aRoomInvite(), aRoomInvite(), aRoomInvite(), aRoomInvite()) +private val AN_ERROR = RuntimeException() + +class ProfileUseCaseTest { + + private val fakeChatEngine = FakeChatEngine() + private val fakeErrorTracker = FakeErrorTracker() + + private val useCase = ProfileUseCase(fakeChatEngine, fakeErrorTracker) + + @Test + fun `given me and invites, when fetching content, then emits content`() = runTest { + fakeChatEngine.givenMe(forceRefresh = true).returns(A_ME) + fakeChatEngine.givenInvites().emits(AN_INVITES_LIST) + + val result = useCase.content().first() + + result shouldBeEqualTo Lce.Content(Page.Profile.Content(A_ME, invitationsCount = AN_INVITES_LIST.size)) + } + + @Test + fun `given me fails, when fetching content, then emits error`() = runTest { + fakeChatEngine.givenMe(forceRefresh = true).throws(AN_ERROR) + fakeChatEngine.givenInvites().emits(emptyList()) + + val result = useCase.content().first() + + result shouldBeEqualTo Lce.Error(AN_ERROR) + } +} \ No newline at end of file