Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider {
HomeScreen(
onNavigateToSetting = { navigator.navigate(SettingKey) },
onOpenConversation = { conversationId -> navigator.navigate(ChatRoomKey(conversationId)) },
isActive = navigator.state.currentKey == HomeKey,
)
}
entry<ArchiveKey> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
Expand Down Expand Up @@ -64,16 +65,26 @@ import kotlin.math.roundToInt
fun HomeScreen(
onNavigateToSetting: () -> Unit,
onOpenConversation: (Long) -> Unit,
isActive: Boolean = true,
modifier: Modifier = Modifier,
viewModel: HomeViewModel = hiltViewModel(),
) {
val state by viewModel.collectAsState()
val context = LocalContext.current

// 닉네임 변경 화면에서 저장하고 돌아왔을 때 최신 정보를 다시 불러온다.
LaunchedEffect(Unit) { viewModel.loadUserInfo() }

LaunchedEffect(isActive) {
if (!isActive) return@LaunchedEffect
viewModel.openConversationEvents.collect { conversationId ->
onOpenConversation(conversationId)
}
}

viewModel.collectSideEffect { sideEffect ->
when (sideEffect) {
is HomeSideEffect.NavigateToSetting -> onNavigateToSetting()
is HomeSideEffect.OpenConversation -> onOpenConversation(sideEffect.conversationId)
is HomeSideEffect.ShowToast ->
Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,5 @@ package com.gamss.android.feature.home
sealed interface HomeSideEffect {
data object NavigateToSetting : HomeSideEffect

/** 대화는 이미 만들어졌다. 대화방은 이 id 로 조회만 한다. */
data class OpenConversation(val conversationId: Long) : HomeSideEffect

data class ShowToast(val message: String) : HomeSideEffect
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import com.gamss.android.domain.conversation.takeWithinMessageLimit
import com.gamss.android.domain.emotion.EmotionCharacter
import com.gamss.android.domain.user.GetUserInfoUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import org.orbitmvi.orbit.ContainerHost
import org.orbitmvi.orbit.blockingIntent
import org.orbitmvi.orbit.syntax.Syntax
import org.orbitmvi.orbit.viewmodel.container
import javax.inject.Inject

Expand All @@ -26,8 +27,18 @@ class HomeViewModel @Inject constructor(
private val session: ConversationSession,
) : ViewModel(), ContainerHost<HomeState, HomeSideEffect> {

// init 대신 onCreate 를 쓴다. 구독 시점에 한 번 돌고, 테스트에서 실행 시점을 잡을 수 있다.
override val container = container<HomeState, HomeSideEffect>(HomeState()) { loadUserInfo() }
private val _openConversationEvents = MutableSharedFlow<Long>(replay = 0)
val openConversationEvents = _openConversationEvents.asSharedFlow()

override val container = container<HomeState, HomeSideEffect>(HomeState())

fun loadUserInfo() = intent {
// 실패해도 기존 닉네임은 지우지 않는다. 갱신 시도가 화면에 이미 보이던 값을 날리면 안 된다.
when (val result = getUserInfoUseCase()) {
is AppResult.Success -> reduce { state.copy(isLoading = false, nickname = result.data.nickname) }
is AppResult.Failure -> reduce { state.copy(isLoading = false) }
}
}

fun navigateToSetting() = intent {
postSideEffect(HomeSideEffect.NavigateToSetting)
Expand Down Expand Up @@ -96,16 +107,10 @@ class HomeViewModel @Inject constructor(
// applicationScope 로 돌려서 이 화면을 벗어나도 끊기지 않는다.
viewModelScope.launch { session.finishSend() }
reduce { state.copy(input = "") }
postSideEffect(HomeSideEffect.OpenConversation(result.data.message.conversationId))
_openConversationEvents.emit(result.data.message.conversationId)
}
// 입력은 남겨 둔다. 실패한 문구를 다시 치게 하면 안 된다.
is AppResult.Failure -> postSideEffect(HomeSideEffect.ShowToast(SEND_FAILED))
}
}

private suspend fun Syntax<HomeState, HomeSideEffect>.loadUserInfo() {
// 닉네임을 못 받아도 화면은 성립한다. 세션이 끊긴 경우는 AuthRepository 가 로그인으로 되돌린다.
val nickname = (getUserInfoUseCase() as? AppResult.Success)?.data?.nickname
reduce { state.copy(isLoading = false, nickname = nickname) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ import com.gamss.android.domain.user.UserProfile
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
Expand All @@ -30,7 +34,7 @@ class HomeViewModelTest {
givenUserInfo(nickname = "이소연")

viewModel().test(this) {
runOnCreate()
containerHost.loadUserInfo()
expectState { copy(isLoading = false, nickname = "이소연") }
}
}
Expand All @@ -40,23 +44,58 @@ class HomeViewModelTest {
coEvery { getUserInfoUseCase() } returns AppResult.Failure(IllegalStateException("boom"))

viewModel().test(this) {
runOnCreate()
containerHost.loadUserInfo()
expectState { copy(isLoading = false, nickname = null) }
}
}

// 닉네임 변경 화면에서 돌아오면 HomeScreen이 loadUserInfo를 다시 호출한다. 그 경로를 흉내낸다.
@Test
fun `걱정을 적고 보내면 대화를 만들고 그 방을 연다`() = runTest {
fun `닉네임 변경 후 돌아와 다시 불러오면 바뀐 값으로 갱신된다`() = runTest {
givenUserInfo(nickname = "이소연")

viewModel().test(this) {
containerHost.loadUserInfo()
expectState { copy(isLoading = false, nickname = "이소연") }

givenUserInfo(nickname = "소연이")
containerHost.loadUserInfo()
expectState { copy(nickname = "소연이") }
}
}

@Test
fun `다시 불러오다 실패해도 이미 보이던 닉네임은 남는다`() = runTest {
givenUserInfo(nickname = "이소연")

viewModel().test(this) {
containerHost.loadUserInfo()
expectState { copy(isLoading = false, nickname = "이소연") }

coEvery { getUserInfoUseCase() } returns AppResult.Failure(IllegalStateException("boom"))
containerHost.loadUserInfo()
expectNoItems()
}
}

@Test
fun `걱정을 적고 보내면 대화를 만들고 그 방을 연다`() = runTest {
val homeViewModel = viewModel()
val openConversation = async(start = CoroutineStart.UNDISPATCHED) {
homeViewModel.openConversationEvents.first()
}

homeViewModel.test(this) {
containerHost.onInputChange(WORRY)
expectState { copy(input = WORRY) }

containerHost.onSubmit()
expectState { copy(isSending = true) }
expectState { copy(isSending = false) }
expectState { copy(input = "") }
expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID))
expectNoItems()
Comment thread
seunghee17 marked this conversation as resolved.
}
assertEquals(NEW_ROOM_ID, openConversation.await())
assertEquals(WORRY, repository.sentContent)
assertNull(repository.sentConversationId)
}
Expand Down Expand Up @@ -88,7 +127,7 @@ class HomeViewModelTest {
expectState { copy(isSending = true) }
expectState { copy(isSending = false) }
expectState { copy(input = "") }
expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID))
expectNoItems()
}
assertEquals(setOf(EmotionCharacter.SADNESS), repository.sentExcludeCharacters)
}
Expand Down Expand Up @@ -147,7 +186,7 @@ class HomeViewModelTest {
expectState { copy(isSending = true, isEmotionPickerExpanded = false) }
expectState { copy(isSending = false) }
expectState { copy(input = "") }
expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID))
expectNoItems()
}
}

Expand Down Expand Up @@ -203,7 +242,7 @@ class HomeViewModelTest {
expectState { copy(isSending = true) }
expectState { copy(isSending = false) }
expectState { copy(input = "") }
expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID))
expectNoItems()

containerHost.onInputChange(filled + "나")
expectState { copy(input = filled) }
Expand Down Expand Up @@ -232,7 +271,7 @@ class HomeViewModelTest {
expectState { copy(isSending = true) }
expectState { copy(isSending = false) }
expectState { copy(input = "") }
expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID))
expectNoItems()

containerHost.onSubmit()
expectNoItems()
Expand All @@ -248,15 +287,15 @@ class HomeViewModelTest {
expectState { copy(isSending = true) }
expectState { copy(isSending = false) }
expectState { copy(input = "") }
expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID))
expectNoItems()

containerHost.onInputChange(SECOND_WORRY)
expectState { copy(input = SECOND_WORRY) }
containerHost.onSubmit()
expectState { copy(isSending = true) }
expectState { copy(isSending = false) }
expectState { copy(input = "") }
expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID))
expectNoItems()
}
assertEquals(listOf(null, null), repository.sentContextSummaries)
}
Expand All @@ -277,11 +316,58 @@ class HomeViewModelTest {
gate.complete(Unit)
expectState { copy(isSending = false) }
expectState { copy(input = "") }
expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID))
expectNoItems()
}
assertEquals(1, gated.sendCount)
}

@Test
fun `대화 생성이 완료되면 이동 이벤트를 발행한다`() = runTest {
val homeViewModel = viewModel()
val openConversation = async(start = CoroutineStart.UNDISPATCHED) {
homeViewModel.openConversationEvents.first()
}

homeViewModel.test(this) {
containerHost.onInputChange(WORRY)
expectState { copy(input = WORRY) }

containerHost.onSubmit()
expectState { copy(isSending = true) }
expectState { copy(isSending = false) }
expectState { copy(input = "") }
expectNoItems()
}

assertEquals(NEW_ROOM_ID, openConversation.await())
}

@Test
fun `이동 이벤트를 수집하는 화면이 없으면 대화방 이동이 재생되지 않는다`() = runTest {
Comment thread
seunghee17 marked this conversation as resolved.
val gate = CompletableDeferred<Unit>()
val gated = RecordingConversationRepository(gate = gate)
val homeViewModel = viewModel(gated)

homeViewModel.test(this) {
containerHost.onInputChange(WORRY)
expectState { copy(input = WORRY) }

containerHost.onSubmit()
expectState { copy(isSending = true) }
expectNoItems()

gate.complete(Unit)
expectState { copy(isSending = false) }
expectState { copy(input = "") }
expectNoItems()
}

val replayedEvent = withTimeoutOrNull(1) {
homeViewModel.openConversationEvents.first()
}
assertNull(replayedEvent)
}

@Test
fun `설정 아이콘을 누르면 설정으로 이동한다`() = runTest {
viewModel().test(this) {
Expand Down
Loading