diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 80b14eb..cc6f958 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,6 +65,7 @@ dependencies { implementation(projects.core.navigation) implementation(projects.core.designsystem) implementation(projects.feature.auth) + implementation(projects.feature.chat) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) diff --git a/feature/chat/build.gradle.kts b/feature/chat/build.gradle.kts new file mode 100644 index 0000000..36f136e --- /dev/null +++ b/feature/chat/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + alias(libs.plugins.todakun.feature) +} + +android { + namespace = "com.kikidan.chat" + + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } +} + +dependencies { + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.core.ktx) + implementation(libs.kotlinx.coroutines.core) + + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/feature/chat/consumer-rules.pro b/feature/chat/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature/chat/src/main/AndroidManifest.xml b/feature/chat/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8072ee0 --- /dev/null +++ b/feature/chat/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt new file mode 100644 index 0000000..1ce5dc1 --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt @@ -0,0 +1,234 @@ +package com.kikidan.chat + +import androidx.lifecycle.ViewModel +import com.kikidan.chat.model.ChatSideEffect +import com.kikidan.chat.model.ChatState +import com.kikidan.chat.model.StreamingChatState +import com.kikidan.domain.model.chat.ChatAction +import com.kikidan.domain.model.chat.ChatMessage +import com.kikidan.domain.model.chat.ChatStreamEvent +import com.kikidan.domain.model.chat.MessageRole +import com.kikidan.domain.model.chat.MessageStatus +import com.kikidan.domain.usecase.GetChatEntryUseCase +import com.kikidan.domain.usecase.GetConversationDetailUseCase +import com.kikidan.domain.usecase.SendChatMessageUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.transform +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.Syntax +import org.orbitmvi.orbit.viewmodel.container +import java.time.Instant +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +@HiltViewModel +class ChatViewModel + @Inject + constructor( + private val getChatEntry: GetChatEntryUseCase, + private val getConversationDetail: GetConversationDetailUseCase, + private val sendChatMessage: SendChatMessageUseCase, + ) : ViewModel(), + ContainerHost { + override val container = container(ChatState()) + + /** 화면 진입 시 1회. conversationId가 있으면 과거 대화를 먼저 채운다. */ + fun load(conversationId: String?) = + intent { + reduce { state.copy(conversationId = conversationId, isLoading = true) } + + getChatEntry() + .onSuccess { entry -> + reduce { + state.copy( + greeting = entry.greeting, + suggestions = entry.suggestions.toPersistentList(), + quota = entry.quota, + ) + } + }.onFailure { postSideEffect(ChatSideEffect.Error(it)) } + + if (conversationId != null) { + getConversationDetail(conversationId) + .onSuccess { reduce { state.copy(messages = it.messages.toPersistentList()) } } + .onFailure { postSideEffect(ChatSideEffect.Error(it)) } + } + + reduce { state.copy(isLoading = false) } + } + + fun onInputChange(value: String) = + intent { + reduce { state.copy(input = value.take(SendChatMessageUseCase.MAX_CONTENT_LENGTH)) } + } + + fun onSendClick() = + intent { + val content = state.input + reduce { state.copy(input = "") } + send(content) + } + + fun onSuggestionClick(seedPrompt: String) = intent { send(seedPrompt) } + + fun startNewConversation() = + intent { + reduce { + state.copy( + conversationId = null, + messages = persistentListOf(), + streamingChatState = StreamingChatState.Idle, + input = "", + ) + } + } + + // 전송 진입점이 여러 개이므로 가드를 여기 한 곳에만 둔다 (설계 2-7). + private suspend fun Syntax.send(content: String) { + if (state.streamingChatState !is StreamingChatState.Idle) return + + val conversationId = state.conversationId + val placeholder = localUserMessage(content.trim()) + reduce { + state.copy( + messages = state.messages.adding(placeholder), + streamingChatState = StreamingChatState.Thinking, + ) + } + + var streamConversationId: String? = conversationId + var assistantMessageId: String? = null + var pendingAction: ChatAction? = null + + try { + sendChatMessage(conversationId, content) + .transform { result -> + when (val event = result.getOrElse { throw it }) { + is ChatStreamEvent.Start -> { + val result = onStreamingStart(placeholder, event) + streamConversationId = result.first + assistantMessageId = result.second + } + + is ChatStreamEvent.Delta -> { + emit(event.text) + } + + is ChatStreamEvent.Action -> { + pendingAction = event.action + } + + is ChatStreamEvent.Done -> { + assistantMessageId = event.assistantMessageId + } + + is ChatStreamEvent.Error -> { + postSideEffect(ChatSideEffect.ShowStreamingErrorMessage(event.message)) + } + } + }.typewriter() + .collect { shown -> + reduce { + state.copy( + streamingChatState = StreamingChatState.Typing(shown), + ) + } + } + + onSteamingDone( + streamConversationId, + assistantMessageId, + pendingAction, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + reduce { + state.copy( + streamingChatState = StreamingChatState.Idle, + ) + } + postSideEffect(ChatSideEffect.Error(e)) + } + } + + private suspend fun Syntax.onStreamingStart( + placeholder: ChatMessage, + event: ChatStreamEvent.Start, + ): Pair { + reduce { + state.copy( + // 낙관적 메시지의 로컬 id를 서버가 준 진짜 id로 교체. + messages = + state.messages + .map { msg -> + if (msg.id == placeholder.id) { + msg.copy( + id = event.userMessageId, + status = MessageStatus.COMPLETED, + ) + } else { + msg + } + }.toPersistentList(), + quota = event.quota, + ) + } + return event.conversationId to event.assistantMessageId + } + + private suspend fun Syntax.onSteamingDone( + streamConversationId: String?, + assistantMessageId: String?, + pendingAction: ChatAction?, + ) { + reduce { + val currentStreamingState = state.streamingChatState + state.copy( + conversationId = streamConversationId, + messages = + if (currentStreamingState is StreamingChatState.Typing) { + state.messages.adding( + assistantMessage( + id = assistantMessageId, + content = currentStreamingState.streamingText, + action = pendingAction, + ), + ) + } else { + state.messages + }, + streamingChatState = StreamingChatState.Idle, + ) + } + } + } + +private fun localUserMessage(content: String) = + ChatMessage( + id = "local-user-${System.currentTimeMillis()}", + role = MessageRole.USER, + content = content, + status = MessageStatus.COMPLETED, + action = null, + createdAt = Instant.now(), + ) + +/** + * 서버는 done에 텍스트/시각을 싣지 않으므로(assistantMessageId만 전달) 최종 메시지는 여기서 조립한다. + * id는 start/done이 준 값을 쓰되, start도 못 받고 스트림이 끝난 경우를 대비해 로컬 id로 폴백한다. + */ +private fun assistantMessage( + id: String?, + content: String, + action: ChatAction?, +) = ChatMessage( + id = id ?: "local-assistant-${System.currentTimeMillis()}", + role = MessageRole.ASSISTANT, + content = content, + status = MessageStatus.COMPLETED, + action = action, + createdAt = Instant.now(), +) diff --git a/feature/chat/src/main/java/com/kikidan/chat/TypewriterFlow.kt b/feature/chat/src/main/java/com/kikidan/chat/TypewriterFlow.kt new file mode 100644 index 0000000..20fb6f0 --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/TypewriterFlow.kt @@ -0,0 +1,37 @@ +package com.kikidan.chat + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch + +/** + * 도착 속도(네트워크)와 표시 속도(화면)를 분리한다. + * 업스트림 청크를 버퍼에 쌓고 tick마다 조금씩 잘라 지금까지 보여줄 전체 텍스"를 방출한다. + */ +internal fun Flow.typewriter(tickMillis: Long = 16L): Flow = + channelFlow { + val buffered = StringBuffer() + val upstream = launch { this@typewriter.collect { chunk -> buffered.append(chunk) } } + var shown = 0 + while (true) { + val upstreamDone = !upstream.isActive + when { + shown < buffered.length -> { + shown = + (((buffered.length - shown) / CATCH_UP_DIVISOR) + shown + 1) + .coerceAtMost(buffered.length) + send(buffered.substring(0, shown)) + } + + upstreamDone -> { + return@channelFlow + } + } + delay(tickMillis) + } + } + +// 남은 글자의 1/32 를 매 틱 추가 방출, +// 약 0.35초에 남은 buffer의 절반을 채우는 속도, tickMillis 파라미터와 더불어 실기기에서 체크 후 조정 요망 +private const val CATCH_UP_DIVISOR = 32 diff --git a/feature/chat/src/main/java/com/kikidan/chat/model/ChatSideEffect.kt b/feature/chat/src/main/java/com/kikidan/chat/model/ChatSideEffect.kt new file mode 100644 index 0000000..f67acec --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/model/ChatSideEffect.kt @@ -0,0 +1,11 @@ +package com.kikidan.chat.model + +sealed interface ChatSideEffect { + data class ShowStreamingErrorMessage( + val message: String, + ) : ChatSideEffect + + data class Error( + val e: Throwable, + ) : ChatSideEffect +} diff --git a/feature/chat/src/main/java/com/kikidan/chat/model/ChatState.kt b/feature/chat/src/main/java/com/kikidan/chat/model/ChatState.kt new file mode 100644 index 0000000..ad99615 --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/model/ChatState.kt @@ -0,0 +1,28 @@ +package com.kikidan.chat.model + +import com.kikidan.domain.model.chat.ChatMessage +import com.kikidan.domain.model.chat.ChatQuota +import com.kikidan.domain.model.chat.ChatSuggestion +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf + +data class ChatState( + val conversationId: String? = null, + val isLoading: Boolean = true, + val greeting: String = "", + val suggestions: PersistentList = persistentListOf(), + val quota: ChatQuota? = null, + val messages: PersistentList = persistentListOf(), + val input: String = "", + val streamingChatState: StreamingChatState = StreamingChatState.Idle, +) + +sealed interface StreamingChatState { + data object Idle : StreamingChatState + + data object Thinking : StreamingChatState + + data class Typing( + val streamingText: String = "", + ) : StreamingChatState +} diff --git a/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt b/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt new file mode 100644 index 0000000..06acaec --- /dev/null +++ b/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt @@ -0,0 +1,448 @@ +package com.kikidan.chat + +import com.kikidan.chat.model.ChatSideEffect +import com.kikidan.chat.model.ChatState +import com.kikidan.chat.model.StreamingChatState +import com.kikidan.domain.model.chat.ChatAction +import com.kikidan.domain.model.chat.ChatActionType +import com.kikidan.domain.model.chat.ChatCategory +import com.kikidan.domain.model.chat.ChatEntry +import com.kikidan.domain.model.chat.ChatMessage +import com.kikidan.domain.model.chat.ChatQuota +import com.kikidan.domain.model.chat.ChatStreamEvent +import com.kikidan.domain.model.chat.ChatSuggestion +import com.kikidan.domain.model.chat.Conversation +import com.kikidan.domain.model.chat.MessageRole +import com.kikidan.domain.model.chat.MessageStatus +import com.kikidan.domain.usecase.GetChatEntryUseCase +import com.kikidan.domain.usecase.GetConversationDetailUseCase +import com.kikidan.domain.usecase.SendChatMessageUseCase +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.orbitmvi.orbit.test.test +import java.time.Instant + +@Suppress("ktlint:standard:max-line-length") +class ChatViewModelTest { + @Test + fun `load null + entry 성공 시 suggestions quota greeting 반영된다`() = + runTest { + val fakeRepo = FakeChatRepository().apply { chatEntryResult = Result.success(defaultEntry) } + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.load(null) + // 첫 reduce: copy(conversationId=null, isLoading=true) → 초기 상태와 동일, 미방출 + // getChatEntry 성공 → copy(greeting, suggestions, quota) 변경 + val s1 = awaitState() + assertEquals(defaultEntry.greeting, s1.greeting) + assertEquals(defaultEntry.suggestions, s1.suggestions) + assertEquals(defaultEntry.quota, s1.quota) + assertTrue(s1.isLoading) + // 최종 reduce: isLoading=false + val s2 = awaitState() + assertFalse(s2.isLoading) + assertNull(s2.conversationId) + } + } + + @Test + fun `load null + entry 실패 시 Error 사이드이펙트, isLoading = false`() = + runTest { + val fakeRepo = FakeChatRepository() + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.load(null) + // onFailure → postSideEffect(SE), then reduce isLoading=false + val se = awaitSideEffect() + assertTrue(se is ChatSideEffect.Error) + val s = awaitState() + assertFalse(s.isLoading) + } + } + + @Test + fun `load 기존 conversationId 시 detail 메시지가 messages에 채워진다`() = + runTest { + val msgs = listOf(message("m1", "hi"), message("m2", "hello", MessageRole.ASSISTANT)) + val fakeRepo = + FakeChatRepository().apply { + chatEntryResult = Result.success(defaultEntry) + conversationDetailResult = Result.success(conversation("c-1", msgs)) + } + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.load("c-1") + // 1: conversationId="c-1" 설정 (변경) + val s1 = awaitState() + assertEquals("c-1", s1.conversationId) + // 2: getChatEntry 성공 + awaitState() + // 3: getConversationDetail 성공 → messages + awaitState() + // 4: isLoading=false + val s4 = awaitState() + assertFalse(s4.isLoading) + assertEquals("c-1", s4.conversationId) + assertEquals(msgs, s4.messages) + } + } + + @Test + fun `send 후 사용자 메시지가 즉시 messages에 추가된다`() = + runTest { + val fakeRepo = + FakeChatRepository().apply { + streamEvents = + listOf( + Result.success(ChatStreamEvent.Start("c1", "u1", "a1", defaultEntry.quota)), + Result.success(ChatStreamEvent.Done("a1")), + ) + } + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + // 첫 상태: THINKING + user msg Completed + val s1 = awaitState() + assertEquals(StreamingChatState.Thinking, s1.streamingChatState) + assertTrue(s1.messages.isNotEmpty()) + assertEquals("안녕", s1.messages.first().content) + assertEquals(MessageRole.USER, s1.messages.first().role) + assertEquals(MessageStatus.COMPLETED, s1.messages.first().status) + cancelAndIgnoreRemainingItems() + } + } + + @Test + fun `Delta 수신 시, streamingText 가 단조 증가한다`() = + runTest { + val fakeRepo = + FakeChatRepository().apply { + streamEvents = + listOf( + Result.success(ChatStreamEvent.Start("c1", "u1", "a1", defaultEntry.quota)), + Result.success(ChatStreamEvent.Delta("안녕하세요")), + Result.success(ChatStreamEvent.Done("a1")), + ) + } + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("질문") + val typingStates = mutableListOf() + var s = awaitState() + while (s.streamingChatState != StreamingChatState.Idle) { + if (s.streamingChatState is StreamingChatState.Typing) typingStates.add(s) + s = awaitState() + } + assertTrue("TYPING 상태가 존재해야 함", typingStates.isNotEmpty()) + for (i in 1 until typingStates.size) { + val chatState = typingStates[i - 1].streamingChatState as StreamingChatState.Typing + val currentChatState = typingStates[i].streamingChatState as StreamingChatState.Typing + assertTrue( + "streamingText 단조 증가 실패: '$chatState' → '$chatState'", + currentChatState.streamingText.startsWith(chatState.streamingText), + ) + } + } + } + + @Test + fun `Done 수신 시 messages 마지막이 assistantMessageId로 COMPLETED 상태의 ASSISTANT 메시지가 된다`() = + runTest { + val fakeRepo = + FakeChatRepository().apply { + streamEvents = + listOf( + Result.success( + ChatStreamEvent.Start("c1", "u1", "a1", defaultEntry.quota), + ), + Result.success(ChatStreamEvent.Delta("응답 텍스트")), + Result.success(ChatStreamEvent.Done("a1")), + ) + } + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("질문") + var s = awaitState() + while (s.streamingChatState != StreamingChatState.Idle) s = awaitState() + assertEquals(StreamingChatState.Idle, s.streamingChatState) + val assistant = s.messages.last() + assertEquals("a1", assistant.id) + assertEquals(MessageRole.ASSISTANT, assistant.role) + assertEquals(MessageStatus.COMPLETED, assistant.status) + assertEquals("응답 텍스트", assistant.content) + } + } + + @Test + fun `Start의 conversationId가 저장되어 두 번째 send 시 Fake에 전달된다`() = + runTest { + val fakeRepo = + FakeChatRepository().apply { + streamEvents = + listOf( + Result.success(ChatStreamEvent.Start("c-from-server", "u1", "a1", defaultEntry.quota)), + Result.success(ChatStreamEvent.Done("a1")), + ) + } + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("첫 번째") + var s = awaitState() + while (s.streamingChatState != StreamingChatState.Idle) s = awaitState() + assertEquals("c-from-server", s.conversationId) + + fakeRepo.streamEvents = + listOf( + Result.success(ChatStreamEvent.Start("c-from-server", "u2", "a2", defaultEntry.quota)), + Result.success(ChatStreamEvent.Done("a2")), + ) + + containerHost.onSuggestionClick("두 번째") + s = awaitState() + while (s.streamingChatState !is StreamingChatState.Idle) s = awaitState() + + assertEquals("c-from-server", fakeRepo.lastSentConversationId) + assertEquals(2, fakeRepo.sendCallCount) + } + } + + @Test + fun `Start의 userMessageId로 낙관적 사용자 메시지 id 가 교체된다`() = + runTest { + val fakeRepo = + FakeChatRepository().apply { + streamEvents = + listOf( + Result.success(ChatStreamEvent.Start("c1", "real-user-id", "a1", defaultEntry.quota)), + Result.success(ChatStreamEvent.Done("a1")), + ) + } + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + val s1 = awaitState() + assertEquals(MessageStatus.COMPLETED, s1.messages.first().status) + // S2: Start 수신 → id가 real-user-id로 교체, COMPLETED + val s2 = awaitState() + assertTrue(s2.messages.any { it.id == "real-user-id" && it.status == MessageStatus.COMPLETED }) + cancelAndIgnoreRemainingItems() + } + } + + @Test + fun `Start 수신 직후 quota 가 즉시 반영된다`() = + runTest { + val updatedQuota = ChatQuota(used = 2, limit = 10) + val fakeRepo = + FakeChatRepository().apply { + streamEvents = + listOf( + Result.success(ChatStreamEvent.Start("c1", "u1", "a1", updatedQuota)), + Result.success(ChatStreamEvent.Done("a1")), + ) + } + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + // S1: THINKING/PENDING + awaitState() + // S2: Start 수신 → quota 즉시 반영 + val s2 = awaitState() + assertEquals(updatedQuota, s2.quota) + cancelAndIgnoreRemainingItems() + } + } + + @Test + fun `Action 이벤트의 action 이 최종 어시스턴트 메시지에 포함된다`() = + runTest { + val action = + ChatAction(type = ChatActionType.CALENDAR_ADD, label = "일기 쓰기", category = "record", date = null) + val fakeRepo = + FakeChatRepository().apply { + streamEvents = + listOf( + Result.success(ChatStreamEvent.Start("c1", "u1", "a1", defaultEntry.quota)), + Result.success(ChatStreamEvent.Delta("텍스트")), + Result.success(ChatStreamEvent.Action(action)), + Result.success(ChatStreamEvent.Done("a1")), + ) + } + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + var s = awaitState() + while (s.streamingChatState !is StreamingChatState.Idle) s = awaitState() + val assistant = s.messages.last() + assertEquals(action, assistant.action) + } + } + + @Test + fun `Start 없이 Delta만 오고 스트림 종료 시 local-assistant 폴백 id 로 어시스턴트 메시지가 추가된다`() = + runTest { + val fakeRepo = + FakeChatRepository().apply { + streamEvents = + listOf( + Result.success(ChatStreamEvent.Delta("텍스트만")), + ) + } + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + var s = awaitState() + while (s.streamingChatState !is StreamingChatState.Idle) s = awaitState() + val assistant = s.messages.find { it.role == MessageRole.ASSISTANT } + assertNotNull(assistant) + assertTrue("폴백 id 사용: ${assistant!!.id}", assistant.id.startsWith("local-assistant-")) + assertEquals("텍스트만", assistant.content) + } + } + + @Test + fun `스트림이 Result failure 방출 시 ShowMessage 이벤트가 방출된다`() = + runTest { + val fakeRepo = + FakeChatRepository().apply { + streamEvents = + listOf( + Result.success(ChatStreamEvent.Start("c1", "u1", "a1", defaultEntry.quota)), + Result.failure(IllegalStateException()), + ) + } + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + awaitState() // S1: THINKING/PENDING + awaitState() // S2: Start 수신 후 THINKING/COMPLETED + // catch 블록: reduce(IDLE) → SE 순서 + val s = awaitState() + assertEquals(StreamingChatState.Idle, s.streamingChatState) + val se = awaitSideEffect() + assertEquals(IllegalStateException::class, (se as ChatSideEffect.Error).e::class) + } + } + + @Test + fun `스트리밍 중 send 재호출은 무시되며 Fake 호출 횟수가 1을 유지한다`() = + runTest { + val fakeRepo = + FakeChatRepository().apply { + streamEvents = + listOf( + Result.success(ChatStreamEvent.Delta("텍스트")), + ) + } + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("첫 번째") + // 첫 상태: THINKING (phase != IDLE) + val s1 = awaitState() + assertTrue("THINKING 상태여야 함", s1.streamingChatState != StreamingChatState.Idle) + + // 스트리밍 중 두 번째 send → phase != IDLE이므로 send() 가드에서 즉시 return + containerHost.onSuggestionClick("두 번째") + + // 나머지 상태 소비 + var s = awaitState() + while (s.streamingChatState !is StreamingChatState.Idle) s = awaitState() + + // sendChatMessage 호출은 첫 번째 1회뿐 + assertEquals(1, fakeRepo.sendCallCount) + } + } + + @Test + fun `onInputChange에 501자 입력 시 input length가 500으로 제한된다`() = + runTest { + val fakeRepo = FakeChatRepository() + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.onInputChange("a".repeat(501)) + val s = awaitState() + assertEquals(500, s.input.length) + } + } + + @Test + fun `startNewConversation 호출 시 conversationId = null, messages 비워짐, suggestions 유지`() = + runTest { + val msgs = listOf(message("m1", "hi")) + val fakeRepo = + FakeChatRepository().apply { + chatEntryResult = Result.success(defaultEntry) + conversationDetailResult = Result.success(conversation("c-1", msgs)) + } + val vm = viewModel(fakeRepo) + + vm.test(this) { + containerHost.load("c-1") + awaitState() // conversationId 설정 + awaitState() // entry 로드 + awaitState() // messages 로드 + val afterLoad = awaitState() // isLoading=false + assertEquals("c-1", afterLoad.conversationId) + assertEquals(msgs, afterLoad.messages) + assertEquals(defaultEntry.suggestions, afterLoad.suggestions) + + containerHost.startNewConversation() + val afterNew = awaitState() + assertNull(afterNew.conversationId) + assertTrue(afterNew.messages.isEmpty()) + assertEquals(StreamingChatState.Idle, afterNew.streamingChatState) + assertEquals(defaultEntry.suggestions, afterNew.suggestions) + } + } + + private fun viewModel(fakeRepo: FakeChatRepository): ChatViewModel = + ChatViewModel( + getChatEntry = GetChatEntryUseCase(fakeRepo), + getConversationDetail = GetConversationDetailUseCase(fakeRepo), + sendChatMessage = SendChatMessageUseCase(fakeRepo), + ) + + private val defaultEntry = + ChatEntry( + greeting = "안녕하세요", + suggestions = listOf(ChatSuggestion("😊", "label", "seed", ChatCategory.LOVE)), + quota = ChatQuota(used = 1, limit = 10), + ) + + private fun conversation( + id: String, + messages: List, + ) = Conversation(id = id, title = "대화", messages = messages) + + private fun message( + id: String, + content: String, + role: MessageRole = MessageRole.USER, + ) = ChatMessage( + id = id, + role = role, + content = content, + status = MessageStatus.COMPLETED, + action = null, + createdAt = Instant.now(), + ) +} diff --git a/feature/chat/src/test/java/com/kikidan/chat/FakeChatRepository.kt b/feature/chat/src/test/java/com/kikidan/chat/FakeChatRepository.kt new file mode 100644 index 0000000..6751cde --- /dev/null +++ b/feature/chat/src/test/java/com/kikidan/chat/FakeChatRepository.kt @@ -0,0 +1,41 @@ +package com.kikidan.chat + +import com.kikidan.domain.model.chat.ChatEntry +import com.kikidan.domain.model.chat.ChatStreamEvent +import com.kikidan.domain.model.chat.Conversation +import com.kikidan.domain.model.chat.ConversationSummary +import com.kikidan.domain.repository.ChatRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow + +class FakeChatRepository : ChatRepository { + var chatEntryResult: Result = Result.failure(NotImplementedError("chatEntryResult 미설정")) + var conversationDetailResult: Result = + Result.failure( + NotImplementedError("conversationDetailResult 미설정"), + ) + var streamEvents: List> = emptyList() + + var lastSentConversationId: String? = null + var lastSentContent: String? = null + var sendCallCount: Int = 0 + + override suspend fun getChatEntry(): Result = chatEntryResult + + override fun sendMessage( + conversationId: String?, + content: String, + ): Flow> { + lastSentConversationId = conversationId + lastSentContent = content + sendCallCount++ + return streamEvents.asFlow() + } + + override suspend fun getConversations(): Result> = Result.failure(NotImplementedError()) + + override suspend fun getConversationDetail(conversationId: String): Result = conversationDetailResult + + override suspend fun deleteConversation(conversationId: String): Result = + Result.failure(NotImplementedError()) +} diff --git a/feature/chat/src/test/java/com/kikidan/chat/TypewriterFlowTest.kt b/feature/chat/src/test/java/com/kikidan/chat/TypewriterFlowTest.kt new file mode 100644 index 0000000..ffe9ce0 --- /dev/null +++ b/feature/chat/src/test/java/com/kikidan/chat/TypewriterFlowTest.kt @@ -0,0 +1,133 @@ +package com.kikidan.chat + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TypewriterFlowTest { + @Test + fun `단일 청크를 받으면 여러 번 나눠 방출하고 마지막 값이 전체 텍스트와 같다`() = + runTest { + val emitted = mutableListOf() + + flowOf("안녕하세요").typewriter(tickMillis = 1L).collect { emitted.add(it) } + + assertTrue("방출 횟수 >= 2", emitted.size >= 2) + // 각 방출은 직전 방출의 prefix 확장 + for (i in 1 until emitted.size) { + assertTrue( + "emitted[$i]='${emitted[i]}'이 emitted[${i - 1}]='${emitted[i - 1]}'으로 시작해야 함", + emitted[i].startsWith(emitted[i - 1]), + ) + } + assertEquals("안녕하세요", emitted.last()) + } + + @Test + fun `여러 청크를 순서대로 받으면 누적해서 방출하고 마지막 값이 전체 텍스트와 같다`() = + runTest { + val upstream = + flow { + emit("안") + emit("녕") + emit("!") + } + val emitted = mutableListOf() + + upstream.typewriter(tickMillis = 1L).collect { emitted.add(it) } + + assertEquals("안녕!", emitted.last()) + } + + @Test + fun `업스트림이 대량 청크 직후 즉시 완료되면 남은 버퍼가 전부 방출된 뒤 완료된다`() = + runTest { + // 가장 중요한 케이스: 답변 뒷부분 유실 회귀 방지 + val bigChunk = "a".repeat(50) + val emitted = mutableListOf() + + flowOf(bigChunk).typewriter(tickMillis = 1L).collect { emitted.add(it) } + + assertEquals("버퍼가 다 비워진 뒤 완료: last='${emitted.last()}'", bigChunk, emitted.last()) + assertTrue("업스트림 완료 후에도 여러 번 나눠 방출됨", emitted.size > 1) + } + + @Test + fun `빈 업스트림은 방출 없이 정상 완료된다`() = + runTest { + val emitted = mutableListOf() + + emptyFlow().typewriter(tickMillis = 1L).collect { emitted.add(it) } + + assertTrue(emitted.isEmpty()) + } + + @Test + fun `업스트림이 예외를 throw하면 동일한 예외가 collect 지점으로 전파된다`() = + runTest { + val error = RuntimeException("테스트 오류") + val upstream = flow { throw error } + + val caught = + runCatching { + upstream.typewriter(tickMillis = 1L).collect { } + } + + assertTrue(caught.isFailure) + // kotlinx-coroutines exception recovery may copy the instance; check class + message + val ex = caught.exceptionOrNull() + assertTrue("RuntimeException이어야 함", ex is RuntimeException) + assertEquals(error.message, ex?.message) + } + + @Test + fun `1000자 단일 청크는 catch_up 덕분에 1000틱 미만으로 완료된다`() = + runTest { + val bigText = "x".repeat(1000) + val emitted = mutableListOf() + + flowOf(bigText).typewriter(tickMillis = 1L).collect { emitted.add(it) } + + assertEquals(bigText, emitted.last()) + assertTrue("catch-up 없이는 1000틱, 실제=${emitted.size}", emitted.size < 1000) + } + + @Test + fun `collect 취소 시 예외 없이 종료되고 업스트림도 취소된다`() = + runTest { + var upstreamCancelled = false + val upstream = + flow { + try { + awaitCancellation() + } finally { + upstreamCancelled = true + } + } + + val job = + launch { + upstream.typewriter(tickMillis = 1L).collect { } + } + // runTest 기본 디스패처는 StandardTestDispatcher라 launch가 즉시 실행되지 않는다. + // runCurrent()로 delay(tickMillis) 중단 지점까지 진행시킨 뒤 취소해야 + // 업스트림 collect(awaitCancellation)가 실제로 시작된 상태에서 취소를 검증할 수 있다. + // advanceUntilIdle()은 무한 delay 루프 때문에 타임아웃되므로 쓰지 않는다. + runCurrent() + + job.cancel() + job.join() + + assertTrue("job이 취소됨", job.isCancelled) + assertTrue("업스트림도 취소됨", upstreamCancelled) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 84436b1..7f4fda7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -97,4 +97,5 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradlePlugin" } todakun-android-signing = { id = "todakun.android.signing" } +todakun-feature = { id = "todakun.feature" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6408b97..8cfdbb3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,3 +37,4 @@ include(":core:data-remote") include(":core:navigation") include(":core:designsystem") include(":feature:auth") +include(":feature:chat")