From 212c3cf6ca60b2aff664ddbd1b2b3409641175df Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 4 Aug 2026 14:27:53 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20#77=20feature:chat=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20+=20ChatViewModel(Orbit)=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypewriterFlowTest의 취소 테스트에서 runTest 기본 디스패처를 StandardTestDispatcher가 아닌 UnconfinedTestDispatcher로 오인해 job이 시작되기 전에 취소되던 버그를 runCurrent() 추가로 수정. --- app/build.gradle.kts | 1 + feature/chat/build.gradle.kts | 45 ++ feature/chat/src/main/AndroidManifest.xml | 2 + .../java/com/kikidan/chat/ChatContract.kt | 28 ++ .../java/com/kikidan/chat/ChatViewModel.kt | 200 ++++++++ .../java/com/kikidan/chat/TypewriterFlow.kt | 46 ++ .../com/kikidan/chat/ChatViewModelTest.kt | 432 ++++++++++++++++++ .../com/kikidan/chat/FakeChatRepository.kt | 40 ++ .../com/kikidan/chat/TypewriterFlowTest.kt | 121 +++++ settings.gradle.kts | 1 + 10 files changed, 916 insertions(+) create mode 100644 feature/chat/build.gradle.kts create mode 100644 feature/chat/src/main/AndroidManifest.xml create mode 100644 feature/chat/src/main/java/com/kikidan/chat/ChatContract.kt create mode 100644 feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt create mode 100644 feature/chat/src/main/java/com/kikidan/chat/TypewriterFlow.kt create mode 100644 feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt create mode 100644 feature/chat/src/test/java/com/kikidan/chat/FakeChatRepository.kt create mode 100644 feature/chat/src/test/java/com/kikidan/chat/TypewriterFlowTest.kt 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..39db02a --- /dev/null +++ b/feature/chat/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.kikidan.chat" + compileSdk = 37 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + implementation(projects.core.domain) + implementation(projects.core.designsystem) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.androidx.core.ktx) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.hilt.navigation.compose) + + implementation(libs.orbit.core) + implementation(libs.orbit.viewmodel) + implementation(libs.orbit.compose) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.orbit.test) +} 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/ChatContract.kt b/feature/chat/src/main/java/com/kikidan/chat/ChatContract.kt new file mode 100644 index 0000000..8041971 --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/ChatContract.kt @@ -0,0 +1,28 @@ +package com.kikidan.chat + +import com.kikidan.domain.model.chat.ChatMessage +import com.kikidan.domain.model.chat.ChatQuota +import com.kikidan.domain.model.chat.ChatSuggestion + +data class ChatState( + val conversationId: String? = null, + val isLoading: Boolean = true, + val greeting: String = "", + val suggestions: List = emptyList(), + val quota: ChatQuota? = null, + val messages: List = emptyList(), + val input: String = "", + val phase: ChatPhase = ChatPhase.IDLE, + // 스트리밍 중인 답변. messages에 넣지 않는 이유는 설계 2-6 참조(틱마다 List 복사 회피). + val streamingText: String = "", +) + +enum class ChatPhase { + IDLE, + THINKING, // 전송했고 첫 delta 전 + TYPING, // delta 수신 중 +} + +sealed interface ChatSideEffect { + data class ShowMessage(val message: String) : ChatSideEffect +} 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..891bc9f --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt @@ -0,0 +1,200 @@ +package com.kikidan.chat + +import androidx.lifecycle.ViewModel +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.ChatStreamException +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.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, + quota = entry.quota, + ) + } + }.onFailure { postSideEffect(ChatSideEffect.ShowMessage(it.toUserMessage())) } + + if (conversationId != null) { + getConversationDetail(conversationId) + .onSuccess { reduce { state.copy(messages = it.messages) } } + .onFailure { postSideEffect(ChatSideEffect.ShowMessage(it.toUserMessage())) } + } + + 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 = emptyList(), + streamingText = "", + phase = ChatPhase.IDLE, + input = "", + ) + } + } + + // 전송 진입점이 여러 개이므로 가드를 여기 한 곳에만 둔다 (설계 2-7). + private suspend fun Syntax.send(content: String) { + if (state.phase != ChatPhase.IDLE) return + if (state.quota?.let { it.remaining <= 0 } == true) { + postSideEffect(ChatSideEffect.ShowMessage(QUOTA_EXHAUSTED_MESSAGE)) + return + } + + val conversationId = state.conversationId + val placeholder = localUserMessage(content) + reduce { + state.copy( + messages = state.messages + placeholder, + phase = ChatPhase.THINKING, + streamingText = "", + ) + } + + var streamConversationId: String? = conversationId + var assistantMessageId: String? = null + var pendingAction: ChatAction? = null + + try { + sendChatMessage(conversationId, content) + .transform { result -> + // 모든 실패를 예외 한 채널로 되돌린다 (설계 2-3). + when (val event = result.getOrElse { throw it }) { + is ChatStreamEvent.Start -> { + streamConversationId = event.conversationId + assistantMessageId = event.assistantMessageId + // transform 람다의 수신자는 FlowCollector이므로, + // reduce/state는 외부 SimpleSyntax 수신자로 해석된다 (설계 2, 주의 1). + reduce { + state.copy( + // 낙관적 메시지의 로컬 id를 서버가 준 진짜 id로 교체 (설계 2-6). + messages = + state.messages.map { msg -> + if (msg.id == placeholder.id) { + msg.copy( + id = event.userMessageId, + status = MessageStatus.COMPLETED, + ) + } else { + msg + } + }, + quota = event.quota, + ) + } + } + is ChatStreamEvent.Delta -> emit(event.text) + is ChatStreamEvent.Action -> pendingAction = event.action + is ChatStreamEvent.Done -> assistantMessageId = event.assistantMessageId + } + }.typewriter() + .collect { shown -> + reduce { state.copy(phase = ChatPhase.TYPING, streamingText = shown) } + } + + // typewriter가 완료된 = 버퍼가 다 비워진 시점 (설계 2-2). + reduce { + state.copy( + conversationId = streamConversationId, + messages = + state.messages + + assistantMessage( + id = assistantMessageId, + content = state.streamingText, + action = pendingAction, + ), + streamingText = "", + phase = ChatPhase.IDLE, + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + reduce { state.copy(streamingText = "", phase = ChatPhase.IDLE) } + postSideEffect(ChatSideEffect.ShowMessage(e.toUserMessage())) + } + } + + private companion object { + const val QUOTA_EXHAUSTED_MESSAGE = "오늘 무료 채팅을 모두 사용했어요." + } + } + +private fun Throwable.toUserMessage(): String = + (this as? ChatStreamException)?.message ?: "답변을 받지 못했어요. 잠시 후 다시 시도해 주세요." + +private fun localUserMessage(content: String) = + ChatMessage( + id = "local-user-${System.currentTimeMillis()}", + role = MessageRole.USER, + content = content, + status = MessageStatus.PENDING, + 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..dbad601 --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/TypewriterFlow.kt @@ -0,0 +1,46 @@ +package com.kikidan.chat + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +// 실기기에서 조정할 튜닝 노브. 체감 속도는 서버가 보내는 청크 크기에 좌우된다. +internal const val TYPING_TICK_MS = 16L // 60fps 프레임 간격 +private const val CATCH_UP_DIVISOR = 12 // 남은 글자의 1/12를 매 틱 추가 방출 + +/** + * 도착 속도(네트워크)와 표시 속도(화면)를 분리한다. + * + * 업스트림 청크를 버퍼에 쌓고 tick마다 조금씩 잘라 "지금까지 보여줄 전체 텍스트"를 방출한다. + * 백로그가 작으면 한 글자씩, 쌓이면 자동으로 빨라져 생성 종료 후 혼자 타이핑하는 지연을 막는다. + * 업스트림이 끝나도 버퍼가 다 빌 때까지 방출을 이어간 뒤 완료한다. + * + * channelFlow를 사용하는 이유: 컨슈머가 취소되면 프로듀서 스코프와 그 자식(upstream launch)이 + * 함께 취소돼 업스트림 자원이 정상 해제된다. + */ +internal fun Flow.typewriter(tickMillis: Long = TYPING_TICK_MS): Flow = + channelFlow { + val buffered = MutableStateFlow("") + val upstream = launch { this@typewriter.collect { chunk -> buffered.update { it + chunk } } } + var shown = 0 + while (true) { + // isActive를 value보다 먼저 읽어야 한다. 순서를 바꾸면 + // "value를 읽은 직후 도착한 마지막 청크"를 못 보고 종료할 수 있다. + val upstreamDone = !upstream.isActive + val full = buffered.value + when { + shown < full.length -> { + shown = (shown + 1 + (full.length - shown) / CATCH_UP_DIVISOR) + .coerceAtMost(full.length) + // ponytail: 틱마다 substring이라 전체 O(n^2). 수천 자 답변까지는 무시 가능. + send(full.substring(0, shown)) + } + upstreamDone -> return@channelFlow + else -> Unit + } + delay(tickMillis) + } + } 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..17c6262 --- /dev/null +++ b/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt @@ -0,0 +1,432 @@ +package com.kikidan.chat + +import com.kikidan.domain.model.chat.ChatAction +import com.kikidan.domain.model.chat.ChatEntry +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 + +/** + * Orbit 11의 이벤트 루프는 Dispatchers.Default를 사용하므로 Dispatchers.setMain()으로 + * 제어할 수 없다. orbit-test의 ContainerHost.test {} 를 사용해야 이벤트 루프가 + * 테스트 디스패처로 대체된다. + */ +class ChatViewModelTest { + + private fun makeVm(fakeRepo: FakeChatRepository): ChatViewModel = + ChatViewModel( + getChatEntry = GetChatEntryUseCase(fakeRepo), + getConversationDetail = GetConversationDetailUseCase(fakeRepo), + sendChatMessage = SendChatMessageUseCase(fakeRepo), + ) + + private val defaultEntry = ChatEntry( + greeting = "안녕하세요", + suggestions = listOf(ChatSuggestion("😊", "label", "seed", "cat")), + quota = ChatQuota(used = 1, limit = 10), + ) + + private fun makeConversation( + id: String, + messages: List, + ) = Conversation(id = id, title = "대화", messages = messages) + + private fun makeMsg( + id: String, + content: String, + role: MessageRole = MessageRole.USER, + ) = com.kikidan.domain.model.chat.ChatMessage( + id = id, + role = role, + content = content, + status = MessageStatus.COMPLETED, + action = null, + createdAt = Instant.now(), + ) + + // ─── load() ───────────────────────────────────────────────────────────── + + @Test + fun `load null + entry 성공 시 suggestions quota greeting 반영, isLoading = false`() = runTest { + val fakeRepo = FakeChatRepository().apply { chatEntryResult = Result.success(defaultEntry) } + val vm = makeVm(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 실패 시 ShowMessage 사이드이펙트, isLoading = false`() = runTest { + val fakeRepo = FakeChatRepository() + val vm = makeVm(fakeRepo) + + vm.test(this) { + containerHost.load(null) + // onFailure → postSideEffect(SE), then reduce isLoading=false + val se = awaitSideEffect() + assertTrue(se is ChatSideEffect.ShowMessage) + val s = awaitState() + assertFalse(s.isLoading) + } + } + + @Test + fun `load 기존 conversationId 시 detail 메시지가 messages에 채워진다`() = runTest { + val msgs = listOf(makeMsg("m1", "hi"), makeMsg("m2", "hello", MessageRole.ASSISTANT)) + val fakeRepo = FakeChatRepository().apply { + chatEntryResult = Result.success(defaultEntry) + conversationDetailResult = Result.success(makeConversation("c-1", msgs)) + } + val vm = makeVm(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) + } + } + + // ─── send() / 전송 파이프라인 ───────────────────────────────────────────── + + @Test + fun `send 후 사용자 메시지가 즉시 messages에 추가되고 phase = THINKING`() = runTest { + val fakeRepo = FakeChatRepository().apply { + streamEvents = listOf( + Result.success(ChatStreamEvent.Start("c1", "u1", "a1", defaultEntry.quota)), + Result.success(ChatStreamEvent.Done("a1")), + ) + } + val vm = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + // 첫 상태: THINKING + user msg PENDING + val s1 = awaitState() + assertEquals(ChatPhase.THINKING, s1.phase) + assertTrue(s1.messages.isNotEmpty()) + assertEquals("안녕", s1.messages.first().content) + assertEquals(MessageRole.USER, s1.messages.first().role) + assertEquals(MessageStatus.PENDING, s1.messages.first().status) + cancelAndIgnoreRemainingItems() + } + } + + @Test + fun `Delta 수신 시 phase = TYPING, 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 = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("질문") + val typingStates = mutableListOf() + var s = awaitState() + while (s.phase != ChatPhase.IDLE) { + if (s.phase == ChatPhase.TYPING) typingStates.add(s) + s = awaitState() + } + assertTrue("TYPING 상태가 존재해야 함", typingStates.isNotEmpty()) + for (i in 1 until typingStates.size) { + assertTrue( + "streamingText 단조 증가 실패: '${typingStates[i - 1].streamingText}' → '${typingStates[i].streamingText}'", + typingStates[i].streamingText.startsWith(typingStates[i - 1].streamingText), + ) + } + } + } + + @Test + fun `Done 수신 시 messages 마지막이 assistantMessageId로 COMPLETED 상태의 ASSISTANT 메시지, streamingText = empty, phase = IDLE`() = 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 = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("질문") + var s = awaitState() + while (s.phase != ChatPhase.IDLE) s = awaitState() + assertEquals(ChatPhase.IDLE, s.phase) + assertEquals("", s.streamingText) + 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 = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("첫 번째") + var s = awaitState() + while (s.phase != ChatPhase.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.phase != ChatPhase.IDLE) s = awaitState() + + assertEquals("c-from-server", fakeRepo.lastSentConversationId) + assertEquals(2, fakeRepo.sendCallCount) + } + } + + @Test + fun `Start의 userMessageId로 낙관적 사용자 메시지 id 가 교체되고 status = COMPLETED`() = 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 = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + // S1: THINKING/PENDING (로컬 placeholder id) + val s1 = awaitState() + assertEquals(MessageStatus.PENDING, 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 = makeVm(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 = "diary", 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 = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + var s = awaitState() + while (s.phase != ChatPhase.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 = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + var s = awaitState() + while (s.phase != ChatPhase.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, phase = IDLE, streamingText = empty`() = runTest { + val fakeRepo = FakeChatRepository().apply { + streamEvents = listOf( + Result.success(ChatStreamEvent.Start("c1", "u1", "a1", defaultEntry.quota)), + Result.failure(com.kikidan.domain.model.chat.ChatStreamException("ERR", "서버 오류")), + ) + } + val vm = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + awaitState() // S1: THINKING/PENDING + awaitState() // S2: Start 수신 후 THINKING/COMPLETED + // catch 블록: reduce(IDLE) → SE 순서 + val s = awaitState() + assertEquals(ChatPhase.IDLE, s.phase) + assertEquals("", s.streamingText) + val se = awaitSideEffect() + assertEquals("서버 오류", (se as ChatSideEffect.ShowMessage).message) + } + } + + @Test + fun `스트리밍 중 send 재호출은 무시된다 - Fake 호출 횟수 1 유지`() = runTest { + val fakeRepo = FakeChatRepository().apply { + streamEvents = listOf( + Result.success(ChatStreamEvent.Delta("텍스트")), + ) + } + val vm = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("첫 번째") + // 첫 상태: THINKING (phase != IDLE) + val s1 = awaitState() + assertTrue("THINKING 상태여야 함", s1.phase != ChatPhase.IDLE) + + // 스트리밍 중 두 번째 send → phase != IDLE이므로 send() 가드에서 즉시 return + containerHost.onSuggestionClick("두 번째") + + // 나머지 상태 소비 + var s = awaitState() + while (s.phase != ChatPhase.IDLE) s = awaitState() + + // sendChatMessage 호출은 첫 번째 1회뿐 + assertEquals(1, fakeRepo.sendCallCount) + } + } + + @Test + fun `quota remaining = 0 에서 send 시 전송 없이 ShowMessage`() = runTest { + val exhaustedQuota = ChatQuota(used = 10, limit = 10) // remaining = 0 + val fakeRepo = FakeChatRepository().apply { + chatEntryResult = Result.success(ChatEntry("", emptyList(), exhaustedQuota)) + } + val vm = makeVm(fakeRepo) + + vm.test(this) { + containerHost.load(null) + awaitState() // quota 설정 + val s2 = awaitState() // isLoading=false + assertEquals(0, s2.quota!!.remaining) + + containerHost.onSuggestionClick("안녕") + val se = awaitSideEffect() + assertTrue(se is ChatSideEffect.ShowMessage) + assertEquals(0, fakeRepo.sendCallCount) + } + } + + @Test + fun `onInputChange에 501자 입력 시 input length = 500`() = runTest { + val fakeRepo = FakeChatRepository() + val vm = makeVm(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(makeMsg("m1", "hi")) + val fakeRepo = FakeChatRepository().apply { + chatEntryResult = Result.success(defaultEntry) + conversationDetailResult = Result.success(makeConversation("c-1", msgs)) + } + val vm = makeVm(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(ChatPhase.IDLE, afterNew.phase) + assertEquals("", afterNew.streamingText) + assertEquals(defaultEntry.suggestions, afterNew.suggestions) + } + } +} 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..bd89358 --- /dev/null +++ b/feature/chat/src/test/java/com/kikidan/chat/FakeChatRepository.kt @@ -0,0 +1,40 @@ +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..e4ce5ab --- /dev/null +++ b/feature/chat/src/test/java/com/kikidan/chat/TypewriterFlowTest.kt @@ -0,0 +1,121 @@ +package com.kikidan.chat + +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 + +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/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") From 8e1baed24ce9b0f3e24e6b2c023cc65ec27b88e8 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 5 Aug 2026 18:19:33 +0900 Subject: [PATCH 02/11] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatSideEffect.ShowMessage를 제거하고, 예외 객체를 직접 전달하는 Error와 스트리밍 전용 에러인 ShowStreamingErrorMessage로 세분화하여 에러 처리 로직을 개선한다. 채팅 스트림에서 ChatStreamEvent.Error 이벤트를 직접 처리하도록 로직을 추가한다. 사용자 메시지 생성 시 초기 상태를 PENDING에서 COMPLETED로 변경하여 낙관적 업데이트 UI를 조정한다. 또한, 테스트 코드를 포함한 feature/chat 모듈 전반에 ktlint 포맷팅을 적용하고 코드 구조를 정리한다. --- .../java/com/kikidan/chat/ChatContract.kt | 10 +- .../java/com/kikidan/chat/ChatViewModel.kt | 33 +- .../java/com/kikidan/chat/TypewriterFlow.kt | 19 +- .../com/kikidan/chat/ChatViewModelTest.kt | 695 +++++++++--------- .../com/kikidan/chat/FakeChatRepository.kt | 11 +- .../com/kikidan/chat/TypewriterFlowTest.kt | 176 ++--- 6 files changed, 508 insertions(+), 436 deletions(-) diff --git a/feature/chat/src/main/java/com/kikidan/chat/ChatContract.kt b/feature/chat/src/main/java/com/kikidan/chat/ChatContract.kt index 8041971..3a67a0d 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/ChatContract.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/ChatContract.kt @@ -20,9 +20,15 @@ data class ChatState( enum class ChatPhase { IDLE, THINKING, // 전송했고 첫 delta 전 - TYPING, // delta 수신 중 + TYPING, // delta 수신 중 } sealed interface ChatSideEffect { - data class ShowMessage(val message: String) : 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/ChatViewModel.kt b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt index 891bc9f..f864b03 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel 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.ChatStreamException import com.kikidan.domain.model.chat.MessageRole import com.kikidan.domain.model.chat.MessageStatus import com.kikidan.domain.usecase.GetChatEntryUseCase @@ -44,12 +43,12 @@ class ChatViewModel quota = entry.quota, ) } - }.onFailure { postSideEffect(ChatSideEffect.ShowMessage(it.toUserMessage())) } + }.onFailure { postSideEffect(ChatSideEffect.Error(it)) } if (conversationId != null) { getConversationDetail(conversationId) .onSuccess { reduce { state.copy(messages = it.messages) } } - .onFailure { postSideEffect(ChatSideEffect.ShowMessage(it.toUserMessage())) } + .onFailure { postSideEffect(ChatSideEffect.Error(it)) } } reduce { state.copy(isLoading = false) } @@ -86,7 +85,7 @@ class ChatViewModel private suspend fun Syntax.send(content: String) { if (state.phase != ChatPhase.IDLE) return if (state.quota?.let { it.remaining <= 0 } == true) { - postSideEffect(ChatSideEffect.ShowMessage(QUOTA_EXHAUSTED_MESSAGE)) + postSideEffect(ChatSideEffect.ShowStreamingErrorMessage(QUOTA_EXHAUSTED_MESSAGE)) return } @@ -132,9 +131,22 @@ class ChatViewModel ) } } - is ChatStreamEvent.Delta -> emit(event.text) - is ChatStreamEvent.Action -> pendingAction = event.action - is ChatStreamEvent.Done -> assistantMessageId = event.assistantMessageId + + 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 -> @@ -160,7 +172,7 @@ class ChatViewModel throw e } catch (e: Throwable) { reduce { state.copy(streamingText = "", phase = ChatPhase.IDLE) } - postSideEffect(ChatSideEffect.ShowMessage(e.toUserMessage())) + postSideEffect(ChatSideEffect.Error(e)) } } @@ -169,15 +181,12 @@ class ChatViewModel } } -private fun Throwable.toUserMessage(): String = - (this as? ChatStreamException)?.message ?: "답변을 받지 못했어요. 잠시 후 다시 시도해 주세요." - private fun localUserMessage(content: String) = ChatMessage( id = "local-user-${System.currentTimeMillis()}", role = MessageRole.USER, content = content, - status = MessageStatus.PENDING, + status = MessageStatus.COMPLETED, action = null, 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 index dbad601..dc7846d 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/TypewriterFlow.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/TypewriterFlow.kt @@ -8,8 +8,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch // 실기기에서 조정할 튜닝 노브. 체감 속도는 서버가 보내는 청크 크기에 좌우된다. -internal const val TYPING_TICK_MS = 16L // 60fps 프레임 간격 -private const val CATCH_UP_DIVISOR = 12 // 남은 글자의 1/12를 매 틱 추가 방출 +internal const val TYPING_TICK_MS = 16L // 60fps 프레임 간격 +private const val CATCH_UP_DIVISOR = 12 // 남은 글자의 1/12를 매 틱 추가 방출 /** * 도착 속도(네트워크)와 표시 속도(화면)를 분리한다. @@ -33,13 +33,20 @@ internal fun Flow.typewriter(tickMillis: Long = TYPING_TICK_MS): Flow { - shown = (shown + 1 + (full.length - shown) / CATCH_UP_DIVISOR) - .coerceAtMost(full.length) + shown = + (shown + 1 + (full.length - shown) / CATCH_UP_DIVISOR) + .coerceAtMost(full.length) // ponytail: 틱마다 substring이라 전체 O(n^2). 수천 자 답변까지는 무시 가능. send(full.substring(0, shown)) } - upstreamDone -> return@channelFlow - else -> Unit + + upstreamDone -> { + return@channelFlow + } + + else -> { + Unit + } } delay(tickMillis) } diff --git a/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt b/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt index 17c6262..5d0d36a 100644 --- a/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt +++ b/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt @@ -1,6 +1,7 @@ package com.kikidan.chat import com.kikidan.domain.model.chat.ChatAction +import com.kikidan.domain.model.chat.ChatActionType import com.kikidan.domain.model.chat.ChatEntry import com.kikidan.domain.model.chat.ChatQuota import com.kikidan.domain.model.chat.ChatStreamEvent @@ -21,13 +22,8 @@ import org.junit.Test import org.orbitmvi.orbit.test.test import java.time.Instant -/** - * Orbit 11의 이벤트 루프는 Dispatchers.Default를 사용하므로 Dispatchers.setMain()으로 - * 제어할 수 없다. orbit-test의 ContainerHost.test {} 를 사용해야 이벤트 루프가 - * 테스트 디스패처로 대체된다. - */ +@Suppress("ktlint:standard:max-line-length") class ChatViewModelTest { - private fun makeVm(fakeRepo: FakeChatRepository): ChatViewModel = ChatViewModel( getChatEntry = GetChatEntryUseCase(fakeRepo), @@ -35,11 +31,12 @@ class ChatViewModelTest { sendChatMessage = SendChatMessageUseCase(fakeRepo), ) - private val defaultEntry = ChatEntry( - greeting = "안녕하세요", - suggestions = listOf(ChatSuggestion("😊", "label", "seed", "cat")), - quota = ChatQuota(used = 1, limit = 10), - ) + private val defaultEntry = + ChatEntry( + greeting = "안녕하세요", + suggestions = listOf(ChatSuggestion("😊", "label", "seed", "cat")), + quota = ChatQuota(used = 1, limit = 10), + ) private fun makeConversation( id: String, @@ -62,371 +59,413 @@ class ChatViewModelTest { // ─── load() ───────────────────────────────────────────────────────────── @Test - fun `load null + entry 성공 시 suggestions quota greeting 반영, isLoading = false`() = runTest { - val fakeRepo = FakeChatRepository().apply { chatEntryResult = Result.success(defaultEntry) } - val vm = makeVm(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) + fun `load null + entry 성공 시 suggestions quota greeting 반영, isLoading = false`() = + runTest { + val fakeRepo = FakeChatRepository().apply { chatEntryResult = Result.success(defaultEntry) } + val vm = makeVm(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 실패 시 ShowMessage 사이드이펙트, isLoading = false`() = runTest { - val fakeRepo = FakeChatRepository() - val vm = makeVm(fakeRepo) - - vm.test(this) { - containerHost.load(null) - // onFailure → postSideEffect(SE), then reduce isLoading=false - val se = awaitSideEffect() - assertTrue(se is ChatSideEffect.ShowMessage) - val s = awaitState() - assertFalse(s.isLoading) + fun `load null + entry 실패 시 Error 사이드이펙트, isLoading = false`() = + runTest { + val fakeRepo = FakeChatRepository() + val vm = makeVm(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(makeMsg("m1", "hi"), makeMsg("m2", "hello", MessageRole.ASSISTANT)) - val fakeRepo = FakeChatRepository().apply { - chatEntryResult = Result.success(defaultEntry) - conversationDetailResult = Result.success(makeConversation("c-1", msgs)) - } - val vm = makeVm(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) + fun `load 기존 conversationId 시 detail 메시지가 messages에 채워진다`() = + runTest { + val msgs = listOf(makeMsg("m1", "hi"), makeMsg("m2", "hello", MessageRole.ASSISTANT)) + val fakeRepo = + FakeChatRepository().apply { + chatEntryResult = Result.success(defaultEntry) + conversationDetailResult = Result.success(makeConversation("c-1", msgs)) + } + val vm = makeVm(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) + } } - } // ─── send() / 전송 파이프라인 ───────────────────────────────────────────── @Test - fun `send 후 사용자 메시지가 즉시 messages에 추가되고 phase = THINKING`() = runTest { - val fakeRepo = FakeChatRepository().apply { - streamEvents = listOf( - Result.success(ChatStreamEvent.Start("c1", "u1", "a1", defaultEntry.quota)), - Result.success(ChatStreamEvent.Done("a1")), - ) - } - val vm = makeVm(fakeRepo) - - vm.test(this) { - containerHost.onSuggestionClick("안녕") - // 첫 상태: THINKING + user msg PENDING - val s1 = awaitState() - assertEquals(ChatPhase.THINKING, s1.phase) - assertTrue(s1.messages.isNotEmpty()) - assertEquals("안녕", s1.messages.first().content) - assertEquals(MessageRole.USER, s1.messages.first().role) - assertEquals(MessageStatus.PENDING, s1.messages.first().status) - cancelAndIgnoreRemainingItems() + fun `send 후 사용자 메시지가 즉시 messages에 추가되고 phase = THINKING`() = + runTest { + val fakeRepo = + FakeChatRepository().apply { + streamEvents = + listOf( + Result.success(ChatStreamEvent.Start("c1", "u1", "a1", defaultEntry.quota)), + Result.success(ChatStreamEvent.Done("a1")), + ) + } + val vm = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + // 첫 상태: THINKING + user msg Completed + val s1 = awaitState() + assertEquals(ChatPhase.THINKING, s1.phase) + 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 수신 시 phase = TYPING, 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 = makeVm(fakeRepo) - - vm.test(this) { - containerHost.onSuggestionClick("질문") - val typingStates = mutableListOf() - var s = awaitState() - while (s.phase != ChatPhase.IDLE) { - if (s.phase == ChatPhase.TYPING) typingStates.add(s) - s = awaitState() - } - assertTrue("TYPING 상태가 존재해야 함", typingStates.isNotEmpty()) - for (i in 1 until typingStates.size) { - assertTrue( - "streamingText 단조 증가 실패: '${typingStates[i - 1].streamingText}' → '${typingStates[i].streamingText}'", - typingStates[i].streamingText.startsWith(typingStates[i - 1].streamingText), - ) + fun `Delta 수신 시 phase = TYPING, 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 = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("질문") + val typingStates = mutableListOf() + var s = awaitState() + while (s.phase != ChatPhase.IDLE) { + if (s.phase == ChatPhase.TYPING) typingStates.add(s) + s = awaitState() + } + assertTrue("TYPING 상태가 존재해야 함", typingStates.isNotEmpty()) + for (i in 1 until typingStates.size) { + assertTrue( + "streamingText 단조 증가 실패: '${typingStates[i - 1].streamingText}' → '${typingStates[i].streamingText}'", + typingStates[i].streamingText.startsWith(typingStates[i - 1].streamingText), + ) + } } } - } @Test - fun `Done 수신 시 messages 마지막이 assistantMessageId로 COMPLETED 상태의 ASSISTANT 메시지, streamingText = empty, phase = IDLE`() = 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 = makeVm(fakeRepo) - - vm.test(this) { - containerHost.onSuggestionClick("질문") - var s = awaitState() - while (s.phase != ChatPhase.IDLE) s = awaitState() - assertEquals(ChatPhase.IDLE, s.phase) - assertEquals("", s.streamingText) - val assistant = s.messages.last() - assertEquals("a1", assistant.id) - assertEquals(MessageRole.ASSISTANT, assistant.role) - assertEquals(MessageStatus.COMPLETED, assistant.status) - assertEquals("응답 텍스트", assistant.content) + fun `Done 수신 시 messages 마지막이 assistantMessageId로 COMPLETED 상태의 ASSISTANT 메시지, streamingText = empty, phase = IDLE`() = + 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 = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("질문") + var s = awaitState() + while (s.phase != ChatPhase.IDLE) s = awaitState() + assertEquals(ChatPhase.IDLE, s.phase) + assertEquals("", s.streamingText) + 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 = makeVm(fakeRepo) - - vm.test(this) { - containerHost.onSuggestionClick("첫 번째") - var s = awaitState() - while (s.phase != ChatPhase.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.phase != ChatPhase.IDLE) s = awaitState() + 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 = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("첫 번째") + var s = awaitState() + while (s.phase != ChatPhase.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.phase != ChatPhase.IDLE) s = awaitState() - assertEquals("c-from-server", fakeRepo.lastSentConversationId) - assertEquals(2, fakeRepo.sendCallCount) + assertEquals("c-from-server", fakeRepo.lastSentConversationId) + assertEquals(2, fakeRepo.sendCallCount) + } } - } @Test - fun `Start의 userMessageId로 낙관적 사용자 메시지 id 가 교체되고 status = COMPLETED`() = 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 = makeVm(fakeRepo) - - vm.test(this) { - containerHost.onSuggestionClick("안녕") - // S1: THINKING/PENDING (로컬 placeholder id) - val s1 = awaitState() - assertEquals(MessageStatus.PENDING, 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() + fun `Start의 userMessageId로 낙관적 사용자 메시지 id 가 교체되고 status = COMPLETED`() = + 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 = makeVm(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 = makeVm(fakeRepo) - - vm.test(this) { - containerHost.onSuggestionClick("안녕") - // S1: THINKING/PENDING - awaitState() - // S2: Start 수신 → quota 즉시 반영 - val s2 = awaitState() - assertEquals(updatedQuota, s2.quota) - cancelAndIgnoreRemainingItems() + 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 = makeVm(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 = "diary", 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 = makeVm(fakeRepo) - - vm.test(this) { - containerHost.onSuggestionClick("안녕") - var s = awaitState() - while (s.phase != ChatPhase.IDLE) s = awaitState() - val assistant = s.messages.last() - assertEquals(action, assistant.action) + 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 = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + var s = awaitState() + while (s.phase != ChatPhase.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 = makeVm(fakeRepo) - - vm.test(this) { - containerHost.onSuggestionClick("안녕") - var s = awaitState() - while (s.phase != ChatPhase.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) + fun `Start 없이 Delta만 오고 스트림 종료 시 local-assistant 폴백 id 로 어시스턴트 메시지가 추가된다`() = + runTest { + val fakeRepo = + FakeChatRepository().apply { + streamEvents = + listOf( + Result.success(ChatStreamEvent.Delta("텍스트만")), + ) + } + val vm = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + var s = awaitState() + while (s.phase != ChatPhase.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, phase = IDLE, streamingText = empty`() = runTest { - val fakeRepo = FakeChatRepository().apply { - streamEvents = listOf( - Result.success(ChatStreamEvent.Start("c1", "u1", "a1", defaultEntry.quota)), - Result.failure(com.kikidan.domain.model.chat.ChatStreamException("ERR", "서버 오류")), - ) - } - val vm = makeVm(fakeRepo) - - vm.test(this) { - containerHost.onSuggestionClick("안녕") - awaitState() // S1: THINKING/PENDING - awaitState() // S2: Start 수신 후 THINKING/COMPLETED - // catch 블록: reduce(IDLE) → SE 순서 - val s = awaitState() - assertEquals(ChatPhase.IDLE, s.phase) - assertEquals("", s.streamingText) - val se = awaitSideEffect() - assertEquals("서버 오류", (se as ChatSideEffect.ShowMessage).message) + fun `스트림이 Result failure 방출 시 ShowMessage, phase = IDLE, streamingText = empty`() = + runTest { + val fakeRepo = + FakeChatRepository().apply { + streamEvents = + listOf( + Result.success(ChatStreamEvent.Start("c1", "u1", "a1", defaultEntry.quota)), + Result.failure(IllegalStateException()), + ) + } + val vm = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("안녕") + awaitState() // S1: THINKING/PENDING + awaitState() // S2: Start 수신 후 THINKING/COMPLETED + // catch 블록: reduce(IDLE) → SE 순서 + val s = awaitState() + assertEquals(ChatPhase.IDLE, s.phase) + assertEquals("", s.streamingText) + 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 = makeVm(fakeRepo) - - vm.test(this) { - containerHost.onSuggestionClick("첫 번째") - // 첫 상태: THINKING (phase != IDLE) - val s1 = awaitState() - assertTrue("THINKING 상태여야 함", s1.phase != ChatPhase.IDLE) - - // 스트리밍 중 두 번째 send → phase != IDLE이므로 send() 가드에서 즉시 return - containerHost.onSuggestionClick("두 번째") - - // 나머지 상태 소비 - var s = awaitState() - while (s.phase != ChatPhase.IDLE) s = awaitState() - - // sendChatMessage 호출은 첫 번째 1회뿐 - assertEquals(1, fakeRepo.sendCallCount) + fun `스트리밍 중 send 재호출은 무시된다 - Fake 호출 횟수 1 유지`() = + runTest { + val fakeRepo = + FakeChatRepository().apply { + streamEvents = + listOf( + Result.success(ChatStreamEvent.Delta("텍스트")), + ) + } + val vm = makeVm(fakeRepo) + + vm.test(this) { + containerHost.onSuggestionClick("첫 번째") + // 첫 상태: THINKING (phase != IDLE) + val s1 = awaitState() + assertTrue("THINKING 상태여야 함", s1.phase != ChatPhase.IDLE) + + // 스트리밍 중 두 번째 send → phase != IDLE이므로 send() 가드에서 즉시 return + containerHost.onSuggestionClick("두 번째") + + // 나머지 상태 소비 + var s = awaitState() + while (s.phase != ChatPhase.IDLE) s = awaitState() + + // sendChatMessage 호출은 첫 번째 1회뿐 + assertEquals(1, fakeRepo.sendCallCount) + } } - } @Test - fun `quota remaining = 0 에서 send 시 전송 없이 ShowMessage`() = runTest { - val exhaustedQuota = ChatQuota(used = 10, limit = 10) // remaining = 0 - val fakeRepo = FakeChatRepository().apply { - chatEntryResult = Result.success(ChatEntry("", emptyList(), exhaustedQuota)) - } - val vm = makeVm(fakeRepo) - - vm.test(this) { - containerHost.load(null) - awaitState() // quota 설정 - val s2 = awaitState() // isLoading=false - assertEquals(0, s2.quota!!.remaining) - - containerHost.onSuggestionClick("안녕") - val se = awaitSideEffect() - assertTrue(se is ChatSideEffect.ShowMessage) - assertEquals(0, fakeRepo.sendCallCount) + fun `quota remaining = 0 에서 send 시 전송 없이 ShowMessage`() = + runTest { + val exhaustedQuota = ChatQuota(used = 10, limit = 10) // remaining = 0 + val fakeRepo = + FakeChatRepository().apply { + chatEntryResult = Result.success(ChatEntry("", emptyList(), exhaustedQuota)) + } + val vm = makeVm(fakeRepo) + + vm.test(this) { + containerHost.load(null) + awaitState() // quota 설정 + val s2 = awaitState() // isLoading=false + assertEquals(0, s2.quota!!.remaining) + + containerHost.onSuggestionClick("안녕") + val se = awaitSideEffect() + assertTrue(se is ChatSideEffect.ShowStreamingErrorMessage) + assertEquals(0, fakeRepo.sendCallCount) + } } - } @Test - fun `onInputChange에 501자 입력 시 input length = 500`() = runTest { - val fakeRepo = FakeChatRepository() - val vm = makeVm(fakeRepo) - - vm.test(this) { - containerHost.onInputChange("a".repeat(501)) - val s = awaitState() - assertEquals(500, s.input.length) + fun `onInputChange에 501자 입력 시 input length = 500`() = + runTest { + val fakeRepo = FakeChatRepository() + val vm = makeVm(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(makeMsg("m1", "hi")) - val fakeRepo = FakeChatRepository().apply { - chatEntryResult = Result.success(defaultEntry) - conversationDetailResult = Result.success(makeConversation("c-1", msgs)) - } - val vm = makeVm(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(ChatPhase.IDLE, afterNew.phase) - assertEquals("", afterNew.streamingText) - assertEquals(defaultEntry.suggestions, afterNew.suggestions) + fun `startNewConversation 호출 시 conversationId = null, messages 비워짐, suggestions 유지`() = + runTest { + val msgs = listOf(makeMsg("m1", "hi")) + val fakeRepo = + FakeChatRepository().apply { + chatEntryResult = Result.success(defaultEntry) + conversationDetailResult = Result.success(makeConversation("c-1", msgs)) + } + val vm = makeVm(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(ChatPhase.IDLE, afterNew.phase) + assertEquals("", afterNew.streamingText) + assertEquals(defaultEntry.suggestions, afterNew.suggestions) + } } - } } diff --git a/feature/chat/src/test/java/com/kikidan/chat/FakeChatRepository.kt b/feature/chat/src/test/java/com/kikidan/chat/FakeChatRepository.kt index bd89358..6751cde 100644 --- a/feature/chat/src/test/java/com/kikidan/chat/FakeChatRepository.kt +++ b/feature/chat/src/test/java/com/kikidan/chat/FakeChatRepository.kt @@ -10,7 +10,10 @@ import kotlinx.coroutines.flow.asFlow class FakeChatRepository : ChatRepository { var chatEntryResult: Result = Result.failure(NotImplementedError("chatEntryResult 미설정")) - var conversationDetailResult: Result = Result.failure(NotImplementedError("conversationDetailResult 미설정")) + var conversationDetailResult: Result = + Result.failure( + NotImplementedError("conversationDetailResult 미설정"), + ) var streamEvents: List> = emptyList() var lastSentConversationId: String? = null @@ -29,11 +32,9 @@ class FakeChatRepository : ChatRepository { return streamEvents.asFlow() } - override suspend fun getConversations(): Result> = - Result.failure(NotImplementedError()) + override suspend fun getConversations(): Result> = Result.failure(NotImplementedError()) - override suspend fun getConversationDetail(conversationId: String): Result = - conversationDetailResult + 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 index e4ce5ab..1e5a60d 100644 --- a/feature/chat/src/test/java/com/kikidan/chat/TypewriterFlowTest.kt +++ b/feature/chat/src/test/java/com/kikidan/chat/TypewriterFlowTest.kt @@ -12,110 +12,120 @@ import org.junit.Assert.assertTrue import org.junit.Test 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]), - ) + 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()) } - assertEquals("안녕하세요", emitted.last()) - } @Test - fun `여러 청크를 순서대로 받으면 누적해서 방출하고 마지막 값이 전체 텍스트와 같다`() = runTest { - val upstream = flow { - emit("안") - emit("녕") - emit("!") + fun `여러 청크를 순서대로 받으면 누적해서 방출하고 마지막 값이 전체 텍스트와 같다`() = + runTest { + val upstream = + flow { + emit("안") + emit("녕") + emit("!") + } + val emitted = mutableListOf() + + upstream.typewriter(tickMillis = 1L).collect { emitted.add(it) } + + assertEquals("안녕!", emitted.last()) } - 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() + fun `업스트림이 대량 청크 직후 즉시 완료되면 남은 버퍼가 전부 방출된 뒤 완료된다`() = + runTest { + // 가장 중요한 케이스: 답변 뒷부분 유실 회귀 방지 + val bigChunk = "a".repeat(50) + val emitted = mutableListOf() - flowOf(bigChunk).typewriter(tickMillis = 1L).collect { emitted.add(it) } + flowOf(bigChunk).typewriter(tickMillis = 1L).collect { emitted.add(it) } - assertEquals("버퍼가 다 비워진 뒤 완료: last='${emitted.last()}'", bigChunk, emitted.last()) - assertTrue("업스트림 완료 후에도 여러 번 나눠 방출됨", emitted.size > 1) - } + assertEquals("버퍼가 다 비워진 뒤 완료: last='${emitted.last()}'", bigChunk, emitted.last()) + assertTrue("업스트림 완료 후에도 여러 번 나눠 방출됨", emitted.size > 1) + } @Test - fun `빈 업스트림은 방출 없이 정상 완료된다`() = runTest { - val emitted = mutableListOf() + fun `빈 업스트림은 방출 없이 정상 완료된다`() = + runTest { + val emitted = mutableListOf() - emptyFlow().typewriter(tickMillis = 1L).collect { emitted.add(it) } + emptyFlow().typewriter(tickMillis = 1L).collect { emitted.add(it) } - assertTrue(emitted.isEmpty()) - } + assertTrue(emitted.isEmpty()) + } @Test - fun `업스트림이 예외를 throw하면 동일한 예외가 collect 지점으로 전파된다`() = runTest { - val error = RuntimeException("테스트 오류") - val upstream = flow { throw error } - - val caught = runCatching { - upstream.typewriter(tickMillis = 1L).collect { } + 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) } - 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) } + fun `1000자 단일 청크는 catch_up 덕분에 1000틱 미만으로 완료된다`() = + runTest { + val bigText = "x".repeat(1000) + val emitted = mutableListOf() - assertEquals(bigText, emitted.last()) - assertTrue("catch-up 없이는 1000틱, 실제=${emitted.size}", emitted.size < 1000) - } + flowOf(bigText).typewriter(tickMillis = 1L).collect { emitted.add(it) } - @Test - fun `collect 취소 시 예외 없이 종료되고 업스트림도 취소된다`() = runTest { - var upstreamCancelled = false - val upstream = flow { - try { - awaitCancellation() - } finally { - upstreamCancelled = true - } + assertEquals(bigText, emitted.last()) + assertTrue("catch-up 없이는 1000틱, 실제=${emitted.size}", emitted.size < 1000) } - val job = launch { - upstream.typewriter(tickMillis = 1L).collect { } + @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) } - // runTest 기본 디스패처는 StandardTestDispatcher라 launch가 즉시 실행되지 않는다. - // runCurrent()로 delay(tickMillis) 중단 지점까지 진행시킨 뒤 취소해야 - // 업스트림 collect(awaitCancellation)가 실제로 시작된 상태에서 취소를 검증할 수 있다. - // advanceUntilIdle()은 무한 delay 루프 때문에 타임아웃되므로 쓰지 않는다. - runCurrent() - - job.cancel() - job.join() - - assertTrue("job이 취소됨", job.isCancelled) - assertTrue("업스트림도 취소됨", upstreamCancelled) - } } From bd20939b907dfc02698e95e283303d3c05443aaf Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 5 Aug 2026 18:32:32 +0900 Subject: [PATCH 03/11] =?UTF-8?q?refactor:=20ChatState=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20StreamingChatState=20sealed=20interface?= =?UTF-8?q?=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존의 `ChatPhase` enum과 `streamingText` 필드를 `StreamingChatState` sealed interface로 통합한다. 상태에 따라 필요한 데이터(`streamingText`)를 `Typing` 상태 내부에 캡슐화하여, Idle이나 Thinking 상태에서 불필요한 데이터에 접근하는 것을 방지하고 상태 전이를 명확하게 표현한다. - `ChatContract.kt`를 제거하고 모델들을 `model` 패키지로 이동 - `ChatViewModel` 내 상태 업데이트 및 메시지 전송 로직을 변경된 상태 모델에 맞춰 수정 - `StreamingChatState.Typing`인 경우에만 `streamingText`를 참조하도록 보장하여 안정성 개선 --- .../java/com/kikidan/chat/ChatContract.kt | 34 --- .../java/com/kikidan/chat/ChatViewModel.kt | 282 +++++++++--------- .../com/kikidan/chat/model/ChatSideEffect.kt | 11 + .../java/com/kikidan/chat/model/ChatState.kt | 27 ++ 4 files changed, 185 insertions(+), 169 deletions(-) delete mode 100644 feature/chat/src/main/java/com/kikidan/chat/ChatContract.kt create mode 100644 feature/chat/src/main/java/com/kikidan/chat/model/ChatSideEffect.kt create mode 100644 feature/chat/src/main/java/com/kikidan/chat/model/ChatState.kt diff --git a/feature/chat/src/main/java/com/kikidan/chat/ChatContract.kt b/feature/chat/src/main/java/com/kikidan/chat/ChatContract.kt deleted file mode 100644 index 3a67a0d..0000000 --- a/feature/chat/src/main/java/com/kikidan/chat/ChatContract.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.kikidan.chat - -import com.kikidan.domain.model.chat.ChatMessage -import com.kikidan.domain.model.chat.ChatQuota -import com.kikidan.domain.model.chat.ChatSuggestion - -data class ChatState( - val conversationId: String? = null, - val isLoading: Boolean = true, - val greeting: String = "", - val suggestions: List = emptyList(), - val quota: ChatQuota? = null, - val messages: List = emptyList(), - val input: String = "", - val phase: ChatPhase = ChatPhase.IDLE, - // 스트리밍 중인 답변. messages에 넣지 않는 이유는 설계 2-6 참조(틱마다 List 복사 회피). - val streamingText: String = "", -) - -enum class ChatPhase { - IDLE, - THINKING, // 전송했고 첫 delta 전 - TYPING, // delta 수신 중 -} - -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/ChatViewModel.kt b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt index f864b03..393bc06 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt @@ -1,6 +1,9 @@ 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 @@ -20,166 +23,175 @@ 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, - quota = entry.quota, - ) - } - }.onFailure { postSideEffect(ChatSideEffect.Error(it)) } - - if (conversationId != null) { - getConversationDetail(conversationId) - .onSuccess { reduce { state.copy(messages = it.messages) } } - .onFailure { postSideEffect(ChatSideEffect.Error(it)) } - } +@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, + quota = entry.quota, + ) + } + }.onFailure { postSideEffect(ChatSideEffect.Error(it)) } - reduce { state.copy(isLoading = false) } + if (conversationId != null) { + getConversationDetail(conversationId) + .onSuccess { reduce { state.copy(messages = it.messages) } } + .onFailure { postSideEffect(ChatSideEffect.Error(it)) } } - fun onInputChange(value: String) = - intent { - reduce { state.copy(input = value.take(SendChatMessageUseCase.MAX_CONTENT_LENGTH)) } - } + reduce { state.copy(isLoading = false) } + } - fun onSendClick() = - intent { - val content = state.input - reduce { state.copy(input = "") } - send(content) - } + fun onInputChange(value: String) = + intent { + reduce { state.copy(input = value.take(SendChatMessageUseCase.MAX_CONTENT_LENGTH)) } + } - fun onSuggestionClick(seedPrompt: String) = intent { send(seedPrompt) } - - fun startNewConversation() = - intent { - reduce { - state.copy( - conversationId = null, - messages = emptyList(), - streamingText = "", - phase = ChatPhase.IDLE, - input = "", - ) - } - } + fun onSendClick() = + intent { + val content = state.input + reduce { state.copy(input = "") } + send(content) + } - // 전송 진입점이 여러 개이므로 가드를 여기 한 곳에만 둔다 (설계 2-7). - private suspend fun Syntax.send(content: String) { - if (state.phase != ChatPhase.IDLE) return - if (state.quota?.let { it.remaining <= 0 } == true) { - postSideEffect(ChatSideEffect.ShowStreamingErrorMessage(QUOTA_EXHAUSTED_MESSAGE)) - return - } + fun onSuggestionClick(seedPrompt: String) = intent { send(seedPrompt) } - val conversationId = state.conversationId - val placeholder = localUserMessage(content) + fun startNewConversation() = + intent { reduce { state.copy( - messages = state.messages + placeholder, - phase = ChatPhase.THINKING, - streamingText = "", + conversationId = null, + messages = emptyList(), + streamingChatState = StreamingChatState.Idle, + input = "", ) } + } - var streamConversationId: String? = conversationId - var assistantMessageId: String? = null - var pendingAction: ChatAction? = null - - try { - sendChatMessage(conversationId, content) - .transform { result -> - // 모든 실패를 예외 한 채널로 되돌린다 (설계 2-3). - when (val event = result.getOrElse { throw it }) { - is ChatStreamEvent.Start -> { - streamConversationId = event.conversationId - assistantMessageId = event.assistantMessageId - // transform 람다의 수신자는 FlowCollector이므로, - // reduce/state는 외부 SimpleSyntax 수신자로 해석된다 (설계 2, 주의 1). - reduce { - state.copy( - // 낙관적 메시지의 로컬 id를 서버가 준 진짜 id로 교체 (설계 2-6). - messages = - state.messages.map { msg -> - if (msg.id == placeholder.id) { - msg.copy( - id = event.userMessageId, - status = MessageStatus.COMPLETED, - ) - } else { - msg - } - }, - quota = event.quota, - ) - } - } + // 전송 진입점이 여러 개이므로 가드를 여기 한 곳에만 둔다 (설계 2-7). + private suspend fun Syntax.send(content: String) { + if (state.streamingChatState is StreamingChatState.Idle) return + if (state.quota?.let { it.remaining <= 0 } == true) { + postSideEffect(ChatSideEffect.ShowStreamingErrorMessage(QUOTA_EXHAUSTED_MESSAGE)) + return + } - is ChatStreamEvent.Delta -> { - emit(event.text) - } + val conversationId = state.conversationId + val placeholder = localUserMessage(content) + reduce { + state.copy( + messages = state.messages + placeholder, + streamingChatState = StreamingChatState.Thinking, + ) + } - is ChatStreamEvent.Action -> { - pendingAction = event.action + var streamConversationId: String? = conversationId + var assistantMessageId: String? = null + var pendingAction: ChatAction? = null + + try { + sendChatMessage(conversationId, content) + .transform { result -> + // 모든 실패를 예외 한 채널로 되돌린다 (설계 2-3). + when (val event = result.getOrElse { throw it }) { + is ChatStreamEvent.Start -> { + streamConversationId = event.conversationId + assistantMessageId = event.assistantMessageId + // transform 람다의 수신자는 FlowCollector이므로, + // reduce/state는 외부 SimpleSyntax 수신자로 해석된다 (설계 2, 주의 1). + reduce { + state.copy( + // 낙관적 메시지의 로컬 id를 서버가 준 진짜 id로 교체 (설계 2-6). + messages = + state.messages.map { msg -> + if (msg.id == placeholder.id) { + msg.copy( + id = event.userMessageId, + status = MessageStatus.COMPLETED, + ) + } else { + msg + } + }, + quota = event.quota, + ) } + } - is ChatStreamEvent.Done -> { - assistantMessageId = event.assistantMessageId - } + is ChatStreamEvent.Delta -> { + emit(event.text) + } - is ChatStreamEvent.Error -> { - postSideEffect(ChatSideEffect.ShowStreamingErrorMessage(event.message)) - } + is ChatStreamEvent.Action -> { + pendingAction = event.action + } + + is ChatStreamEvent.Done -> { + assistantMessageId = event.assistantMessageId } - }.typewriter() - .collect { shown -> - reduce { state.copy(phase = ChatPhase.TYPING, streamingText = shown) } + + is ChatStreamEvent.Error -> { + postSideEffect(ChatSideEffect.ShowStreamingErrorMessage(event.message)) + } + } + }.typewriter() + .collect { shown -> + reduce { + state.copy( + streamingChatState = StreamingChatState.Typing(shown), + ) } + } - // typewriter가 완료된 = 버퍼가 다 비워진 시점 (설계 2-2). - reduce { - state.copy( - conversationId = streamConversationId, - messages = - state.messages + - assistantMessage( - id = assistantMessageId, - content = state.streamingText, - action = pendingAction, - ), - streamingText = "", - phase = ChatPhase.IDLE, + // typewriter가 완료된 = 버퍼가 다 비워진 시점 (설계 2-2). + reduce { + val currentStreamingState = state.streamingChatState + state.copy( + conversationId = streamConversationId, + messages = + if (currentStreamingState is StreamingChatState.Typing) { + state.messages + assistantMessage( + id = assistantMessageId, + content = currentStreamingState.streamingText, + action = pendingAction, + ) + } else { + state.messages + }, + streamingChatState = StreamingChatState.Idle, ) - } - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - reduce { state.copy(streamingText = "", phase = ChatPhase.IDLE) } - postSideEffect(ChatSideEffect.Error(e)) } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + reduce { + state.copy( + streamingChatState = StreamingChatState.Idle, + ) + } + postSideEffect(ChatSideEffect.Error(e)) } + } - private companion object { - const val QUOTA_EXHAUSTED_MESSAGE = "오늘 무료 채팅을 모두 사용했어요." - } + private companion object { + const val QUOTA_EXHAUSTED_MESSAGE = "오늘 무료 채팅을 모두 사용했어요." } +} private fun localUserMessage(content: String) = ChatMessage( 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..27c9ba9 --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/model/ChatState.kt @@ -0,0 +1,27 @@ +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 + +data class ChatState( + val conversationId: String? = null, + val isLoading: Boolean = true, + val greeting: String = "", + val suggestions: List = emptyList(), + val quota: ChatQuota? = null, + val messages: List = emptyList(), + 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 +} + From c4127a7ab26dc6aafde61a991ac5062d92888bf2 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 5 Aug 2026 18:34:05 +0900 Subject: [PATCH 04/11] =?UTF-8?q?style:=20Chat=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatViewModel의 인덴트와 메서드 체이닝 줄바꿈을 일관되게 수정한다. ChatState 및 하위 데이터 클래스에 trailing comma를 추가하여 가독성을 개선하고 향후 변경 시 diff 노이즈를 방지한다. --- .../java/com/kikidan/chat/ChatViewModel.kt | 287 +++++++++--------- .../java/com/kikidan/chat/model/ChatState.kt | 5 +- 2 files changed, 146 insertions(+), 146 deletions(-) diff --git a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt index 393bc06..ce4535f 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt @@ -23,175 +23,176 @@ 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, - quota = entry.quota, - ) - } - }.onFailure { postSideEffect(ChatSideEffect.Error(it)) } + @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, + quota = entry.quota, + ) + } + }.onFailure { postSideEffect(ChatSideEffect.Error(it)) } - if (conversationId != null) { - getConversationDetail(conversationId) - .onSuccess { reduce { state.copy(messages = it.messages) } } - .onFailure { postSideEffect(ChatSideEffect.Error(it)) } + if (conversationId != null) { + getConversationDetail(conversationId) + .onSuccess { reduce { state.copy(messages = it.messages) } } + .onFailure { postSideEffect(ChatSideEffect.Error(it)) } + } + + reduce { state.copy(isLoading = false) } } - reduce { state.copy(isLoading = false) } - } + fun onInputChange(value: String) = + intent { + reduce { state.copy(input = value.take(SendChatMessageUseCase.MAX_CONTENT_LENGTH)) } + } - 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 onSendClick() = - intent { - val content = state.input - reduce { state.copy(input = "") } - send(content) - } + fun onSuggestionClick(seedPrompt: String) = intent { send(seedPrompt) } - fun onSuggestionClick(seedPrompt: String) = intent { send(seedPrompt) } + fun startNewConversation() = + intent { + reduce { + state.copy( + conversationId = null, + messages = emptyList(), + streamingChatState = StreamingChatState.Idle, + input = "", + ) + } + } - fun startNewConversation() = - intent { + // 전송 진입점이 여러 개이므로 가드를 여기 한 곳에만 둔다 (설계 2-7). + private suspend fun Syntax.send(content: String) { + if (state.streamingChatState is StreamingChatState.Idle) return + if (state.quota?.let { it.remaining <= 0 } == true) { + postSideEffect(ChatSideEffect.ShowStreamingErrorMessage(QUOTA_EXHAUSTED_MESSAGE)) + return + } + + val conversationId = state.conversationId + val placeholder = localUserMessage(content) reduce { state.copy( - conversationId = null, - messages = emptyList(), - streamingChatState = StreamingChatState.Idle, - input = "", + messages = state.messages + placeholder, + streamingChatState = StreamingChatState.Thinking, ) } - } - // 전송 진입점이 여러 개이므로 가드를 여기 한 곳에만 둔다 (설계 2-7). - private suspend fun Syntax.send(content: String) { - if (state.streamingChatState is StreamingChatState.Idle) return - if (state.quota?.let { it.remaining <= 0 } == true) { - postSideEffect(ChatSideEffect.ShowStreamingErrorMessage(QUOTA_EXHAUSTED_MESSAGE)) - return - } - - val conversationId = state.conversationId - val placeholder = localUserMessage(content) - reduce { - state.copy( - messages = state.messages + placeholder, - streamingChatState = StreamingChatState.Thinking, - ) - } + var streamConversationId: String? = conversationId + var assistantMessageId: String? = null + var pendingAction: ChatAction? = null + + try { + sendChatMessage(conversationId, content) + .transform { result -> + // 모든 실패를 예외 한 채널로 되돌린다 (설계 2-3). + when (val event = result.getOrElse { throw it }) { + is ChatStreamEvent.Start -> { + streamConversationId = event.conversationId + assistantMessageId = event.assistantMessageId + // transform 람다의 수신자는 FlowCollector이므로, + // reduce/state는 외부 SimpleSyntax 수신자로 해석된다 (설계 2, 주의 1). + reduce { + state.copy( + // 낙관적 메시지의 로컬 id를 서버가 준 진짜 id로 교체 (설계 2-6). + messages = + state.messages.map { msg -> + if (msg.id == placeholder.id) { + msg.copy( + id = event.userMessageId, + status = MessageStatus.COMPLETED, + ) + } else { + msg + } + }, + quota = event.quota, + ) + } + } - var streamConversationId: String? = conversationId - var assistantMessageId: String? = null - var pendingAction: ChatAction? = null - - try { - sendChatMessage(conversationId, content) - .transform { result -> - // 모든 실패를 예외 한 채널로 되돌린다 (설계 2-3). - when (val event = result.getOrElse { throw it }) { - is ChatStreamEvent.Start -> { - streamConversationId = event.conversationId - assistantMessageId = event.assistantMessageId - // transform 람다의 수신자는 FlowCollector이므로, - // reduce/state는 외부 SimpleSyntax 수신자로 해석된다 (설계 2, 주의 1). - reduce { - state.copy( - // 낙관적 메시지의 로컬 id를 서버가 준 진짜 id로 교체 (설계 2-6). - messages = - state.messages.map { msg -> - if (msg.id == placeholder.id) { - msg.copy( - id = event.userMessageId, - status = MessageStatus.COMPLETED, - ) - } else { - msg - } - }, - quota = event.quota, - ) + is ChatStreamEvent.Delta -> { + emit(event.text) } - } - is ChatStreamEvent.Delta -> { - emit(event.text) - } + is ChatStreamEvent.Action -> { + pendingAction = event.action + } - is ChatStreamEvent.Action -> { - pendingAction = event.action - } + is ChatStreamEvent.Done -> { + assistantMessageId = event.assistantMessageId + } - is ChatStreamEvent.Done -> { - assistantMessageId = event.assistantMessageId + is ChatStreamEvent.Error -> { + postSideEffect(ChatSideEffect.ShowStreamingErrorMessage(event.message)) + } } - - is ChatStreamEvent.Error -> { - postSideEffect(ChatSideEffect.ShowStreamingErrorMessage(event.message)) + }.typewriter() + .collect { shown -> + reduce { + state.copy( + streamingChatState = StreamingChatState.Typing(shown), + ) } } - }.typewriter() - .collect { shown -> - reduce { - state.copy( - streamingChatState = StreamingChatState.Typing(shown), - ) - } - } - // typewriter가 완료된 = 버퍼가 다 비워진 시점 (설계 2-2). - reduce { - val currentStreamingState = state.streamingChatState - state.copy( - conversationId = streamConversationId, - messages = - if (currentStreamingState is StreamingChatState.Typing) { - state.messages + assistantMessage( - id = assistantMessageId, - content = currentStreamingState.streamingText, - action = pendingAction, - ) - } else { - state.messages - }, - streamingChatState = StreamingChatState.Idle, + // typewriter가 완료된 = 버퍼가 다 비워진 시점 (설계 2-2). + reduce { + val currentStreamingState = state.streamingChatState + state.copy( + conversationId = streamConversationId, + messages = + if (currentStreamingState is StreamingChatState.Typing) { + state.messages + + assistantMessage( + id = assistantMessageId, + content = currentStreamingState.streamingText, + action = pendingAction, + ) + } else { + state.messages + }, + streamingChatState = StreamingChatState.Idle, ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + reduce { + state.copy( + streamingChatState = StreamingChatState.Idle, + ) + } + postSideEffect(ChatSideEffect.Error(e)) } - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - reduce { - state.copy( - streamingChatState = StreamingChatState.Idle, - ) - } - postSideEffect(ChatSideEffect.Error(e)) } - } - private companion object { - const val QUOTA_EXHAUSTED_MESSAGE = "오늘 무료 채팅을 모두 사용했어요." + private companion object { + const val QUOTA_EXHAUSTED_MESSAGE = "오늘 무료 채팅을 모두 사용했어요." + } } -} private fun localUserMessage(content: String) = ChatMessage( 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 index 27c9ba9..8e07aa1 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/model/ChatState.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/model/ChatState.kt @@ -12,7 +12,7 @@ data class ChatState( val quota: ChatQuota? = null, val messages: List = emptyList(), val input: String = "", - val streamingChatState: StreamingChatState = StreamingChatState.Idle + val streamingChatState: StreamingChatState = StreamingChatState.Idle, ) sealed interface StreamingChatState { @@ -21,7 +21,6 @@ sealed interface StreamingChatState { data object Thinking : StreamingChatState data class Typing( - val streamingText: String = "" + val streamingText: String = "", ) : StreamingChatState } - From 69c6776ae79579f217792f3b6ae61cd19af63d0e Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 5 Aug 2026 23:32:39 +0900 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20ChatViewModel=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=A0=84=EC=86=A1=20=EA=B0=80=EB=93=9C=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=A6=AC=ED=8C=A9=ED=84=B0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `send()` 함수에서 스트리밍 중인 경우 요청을 무시하는 가드 로직이 반대로 되어 있어, `Idle` 상태가 아닐 때 리턴하도록 수정한다. 테스트 코드의 상태 검증 로직을 `ChatPhase`에서 `StreamingChatState` 기반으로 변경하고, 가독성을 위해 헬퍼 메서드 명칭과 테스트 이름을 정리한다. --- .../java/com/kikidan/chat/ChatViewModel.kt | 2 +- .../com/kikidan/chat/ChatViewModelTest.kt | 166 +++++++++--------- 2 files changed, 84 insertions(+), 84 deletions(-) diff --git a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt index ce4535f..a77f899 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt @@ -85,7 +85,7 @@ class ChatViewModel // 전송 진입점이 여러 개이므로 가드를 여기 한 곳에만 둔다 (설계 2-7). private suspend fun Syntax.send(content: String) { - if (state.streamingChatState is StreamingChatState.Idle) return + if (state.streamingChatState !is StreamingChatState.Idle) return if (state.quota?.let { it.remaining <= 0 } == true) { postSideEffect(ChatSideEffect.ShowStreamingErrorMessage(QUOTA_EXHAUSTED_MESSAGE)) return diff --git a/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt b/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt index 5d0d36a..4cfe23d 100644 --- a/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt +++ b/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt @@ -1,8 +1,12 @@ 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.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 @@ -24,45 +28,11 @@ import java.time.Instant @Suppress("ktlint:standard:max-line-length") class ChatViewModelTest { - private fun makeVm(fakeRepo: FakeChatRepository): ChatViewModel = - ChatViewModel( - getChatEntry = GetChatEntryUseCase(fakeRepo), - getConversationDetail = GetConversationDetailUseCase(fakeRepo), - sendChatMessage = SendChatMessageUseCase(fakeRepo), - ) - - private val defaultEntry = - ChatEntry( - greeting = "안녕하세요", - suggestions = listOf(ChatSuggestion("😊", "label", "seed", "cat")), - quota = ChatQuota(used = 1, limit = 10), - ) - - private fun makeConversation( - id: String, - messages: List, - ) = Conversation(id = id, title = "대화", messages = messages) - - private fun makeMsg( - id: String, - content: String, - role: MessageRole = MessageRole.USER, - ) = com.kikidan.domain.model.chat.ChatMessage( - id = id, - role = role, - content = content, - status = MessageStatus.COMPLETED, - action = null, - createdAt = Instant.now(), - ) - - // ─── load() ───────────────────────────────────────────────────────────── - @Test - fun `load null + entry 성공 시 suggestions quota greeting 반영, isLoading = false`() = + fun `load null + entry 성공 시 suggestions quota greeting 반영된다`() = runTest { val fakeRepo = FakeChatRepository().apply { chatEntryResult = Result.success(defaultEntry) } - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.load(null) @@ -84,7 +54,7 @@ class ChatViewModelTest { fun `load null + entry 실패 시 Error 사이드이펙트, isLoading = false`() = runTest { val fakeRepo = FakeChatRepository() - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.load(null) @@ -99,13 +69,13 @@ class ChatViewModelTest { @Test fun `load 기존 conversationId 시 detail 메시지가 messages에 채워진다`() = runTest { - val msgs = listOf(makeMsg("m1", "hi"), makeMsg("m2", "hello", MessageRole.ASSISTANT)) + val msgs = listOf(message("m1", "hi"), message("m2", "hello", MessageRole.ASSISTANT)) val fakeRepo = FakeChatRepository().apply { chatEntryResult = Result.success(defaultEntry) - conversationDetailResult = Result.success(makeConversation("c-1", msgs)) + conversationDetailResult = Result.success(conversation("c-1", msgs)) } - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.load("c-1") @@ -124,10 +94,8 @@ class ChatViewModelTest { } } - // ─── send() / 전송 파이프라인 ───────────────────────────────────────────── - @Test - fun `send 후 사용자 메시지가 즉시 messages에 추가되고 phase = THINKING`() = + fun `send 후 사용자 메시지가 즉시 messages에 추가된다`() = runTest { val fakeRepo = FakeChatRepository().apply { @@ -137,13 +105,13 @@ class ChatViewModelTest { Result.success(ChatStreamEvent.Done("a1")), ) } - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.onSuggestionClick("안녕") // 첫 상태: THINKING + user msg Completed val s1 = awaitState() - assertEquals(ChatPhase.THINKING, s1.phase) + assertEquals(StreamingChatState.Thinking, s1.streamingChatState) assertTrue(s1.messages.isNotEmpty()) assertEquals("안녕", s1.messages.first().content) assertEquals(MessageRole.USER, s1.messages.first().role) @@ -153,7 +121,7 @@ class ChatViewModelTest { } @Test - fun `Delta 수신 시 phase = TYPING, streamingText 가 단조 증가한다`() = + fun `Delta 수신 시, streamingText 가 단조 증가한다`() = runTest { val fakeRepo = FakeChatRepository().apply { @@ -164,28 +132,30 @@ class ChatViewModelTest { Result.success(ChatStreamEvent.Done("a1")), ) } - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.onSuggestionClick("질문") val typingStates = mutableListOf() var s = awaitState() - while (s.phase != ChatPhase.IDLE) { - if (s.phase == ChatPhase.TYPING) typingStates.add(s) + 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 단조 증가 실패: '${typingStates[i - 1].streamingText}' → '${typingStates[i].streamingText}'", - typingStates[i].streamingText.startsWith(typingStates[i - 1].streamingText), + "streamingText 단조 증가 실패: '${chatState}' → '${chatState}'", + currentChatState.streamingText.startsWith(chatState.streamingText), ) } } } @Test - fun `Done 수신 시 messages 마지막이 assistantMessageId로 COMPLETED 상태의 ASSISTANT 메시지, streamingText = empty, phase = IDLE`() = + fun `Done 수신 시 messages 마지막이 assistantMessageId로 COMPLETED 상태의 ASSISTANT 메시지가 된다`() = runTest { val fakeRepo = FakeChatRepository().apply { @@ -198,14 +168,13 @@ class ChatViewModelTest { Result.success(ChatStreamEvent.Done("a1")), ) } - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.onSuggestionClick("질문") var s = awaitState() - while (s.phase != ChatPhase.IDLE) s = awaitState() - assertEquals(ChatPhase.IDLE, s.phase) - assertEquals("", s.streamingText) + 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) @@ -225,12 +194,12 @@ class ChatViewModelTest { Result.success(ChatStreamEvent.Done("a1")), ) } - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.onSuggestionClick("첫 번째") var s = awaitState() - while (s.phase != ChatPhase.IDLE) s = awaitState() + while (s.streamingChatState != StreamingChatState.Idle) s = awaitState() assertEquals("c-from-server", s.conversationId) fakeRepo.streamEvents = @@ -241,7 +210,7 @@ class ChatViewModelTest { containerHost.onSuggestionClick("두 번째") s = awaitState() - while (s.phase != ChatPhase.IDLE) s = awaitState() + while (s.streamingChatState !is StreamingChatState.Idle) s = awaitState() assertEquals("c-from-server", fakeRepo.lastSentConversationId) assertEquals(2, fakeRepo.sendCallCount) @@ -249,7 +218,7 @@ class ChatViewModelTest { } @Test - fun `Start의 userMessageId로 낙관적 사용자 메시지 id 가 교체되고 status = COMPLETED`() = + fun `Start의 userMessageId로 낙관적 사용자 메시지 id 가 교체된다`() = runTest { val fakeRepo = FakeChatRepository().apply { @@ -259,7 +228,7 @@ class ChatViewModelTest { Result.success(ChatStreamEvent.Done("a1")), ) } - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.onSuggestionClick("안녕") @@ -284,7 +253,7 @@ class ChatViewModelTest { Result.success(ChatStreamEvent.Done("a1")), ) } - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.onSuggestionClick("안녕") @@ -312,12 +281,12 @@ class ChatViewModelTest { Result.success(ChatStreamEvent.Done("a1")), ) } - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.onSuggestionClick("안녕") var s = awaitState() - while (s.phase != ChatPhase.IDLE) s = awaitState() + while (s.streamingChatState !is StreamingChatState.Idle) s = awaitState() val assistant = s.messages.last() assertEquals(action, assistant.action) } @@ -333,12 +302,12 @@ class ChatViewModelTest { Result.success(ChatStreamEvent.Delta("텍스트만")), ) } - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.onSuggestionClick("안녕") var s = awaitState() - while (s.phase != ChatPhase.IDLE) 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-")) @@ -347,7 +316,7 @@ class ChatViewModelTest { } @Test - fun `스트림이 Result failure 방출 시 ShowMessage, phase = IDLE, streamingText = empty`() = + fun `스트림이 Result failure 방출 시 ShowMessage 이벤트가 방출된다`() = runTest { val fakeRepo = FakeChatRepository().apply { @@ -357,7 +326,7 @@ class ChatViewModelTest { Result.failure(IllegalStateException()), ) } - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.onSuggestionClick("안녕") @@ -365,15 +334,14 @@ class ChatViewModelTest { awaitState() // S2: Start 수신 후 THINKING/COMPLETED // catch 블록: reduce(IDLE) → SE 순서 val s = awaitState() - assertEquals(ChatPhase.IDLE, s.phase) - assertEquals("", s.streamingText) + assertEquals(StreamingChatState.Idle, s.streamingChatState) val se = awaitSideEffect() assertEquals(IllegalStateException::class, (se as ChatSideEffect.Error).e::class) } } @Test - fun `스트리밍 중 send 재호출은 무시된다 - Fake 호출 횟수 1 유지`() = + fun `스트리밍 중 send 재호출은 무시되며 Fake 호출 횟수가 1을 유지한다`() = runTest { val fakeRepo = FakeChatRepository().apply { @@ -382,20 +350,20 @@ class ChatViewModelTest { Result.success(ChatStreamEvent.Delta("텍스트")), ) } - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.onSuggestionClick("첫 번째") // 첫 상태: THINKING (phase != IDLE) val s1 = awaitState() - assertTrue("THINKING 상태여야 함", s1.phase != ChatPhase.IDLE) + assertTrue("THINKING 상태여야 함", s1.streamingChatState != StreamingChatState.Idle) // 스트리밍 중 두 번째 send → phase != IDLE이므로 send() 가드에서 즉시 return containerHost.onSuggestionClick("두 번째") // 나머지 상태 소비 var s = awaitState() - while (s.phase != ChatPhase.IDLE) s = awaitState() + while (s.streamingChatState !is StreamingChatState.Idle) s = awaitState() // sendChatMessage 호출은 첫 번째 1회뿐 assertEquals(1, fakeRepo.sendCallCount) @@ -403,14 +371,14 @@ class ChatViewModelTest { } @Test - fun `quota remaining = 0 에서 send 시 전송 없이 ShowMessage`() = + fun `quota remaining = 0 에서 send 시 전송 없이 ShowStreamingErrorMessage 이벤트가 방출된다`() = runTest { val exhaustedQuota = ChatQuota(used = 10, limit = 10) // remaining = 0 val fakeRepo = FakeChatRepository().apply { chatEntryResult = Result.success(ChatEntry("", emptyList(), exhaustedQuota)) } - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.load(null) @@ -426,10 +394,10 @@ class ChatViewModelTest { } @Test - fun `onInputChange에 501자 입력 시 input length = 500`() = + fun `onInputChange에 501자 입력 시 input length가 500으로 제한된다`() = runTest { val fakeRepo = FakeChatRepository() - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.onInputChange("a".repeat(501)) @@ -441,13 +409,13 @@ class ChatViewModelTest { @Test fun `startNewConversation 호출 시 conversationId = null, messages 비워짐, suggestions 유지`() = runTest { - val msgs = listOf(makeMsg("m1", "hi")) + val msgs = listOf(message("m1", "hi")) val fakeRepo = FakeChatRepository().apply { chatEntryResult = Result.success(defaultEntry) - conversationDetailResult = Result.success(makeConversation("c-1", msgs)) + conversationDetailResult = Result.success(conversation("c-1", msgs)) } - val vm = makeVm(fakeRepo) + val vm = viewModel(fakeRepo) vm.test(this) { containerHost.load("c-1") @@ -463,9 +431,41 @@ class ChatViewModelTest { val afterNew = awaitState() assertNull(afterNew.conversationId) assertTrue(afterNew.messages.isEmpty()) - assertEquals(ChatPhase.IDLE, afterNew.phase) - assertEquals("", afterNew.streamingText) + 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", "cat")), + 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(), + ) + } From 65d2eaafa26b976f5c14447d708abc0e0ef3b2ce Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Thu, 6 Aug 2026 00:49:28 +0900 Subject: [PATCH 06/11] =?UTF-8?q?refactor:=20ChatViewModel=20=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EB=B0=8D=20=EB=A1=9C=EC=A7=81=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20TypewriterFlow=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatViewModel의 send 메서드 내 스트리밍 시작 및 종료 처리 로직을 onStreamingStart, onSteamingDone으로 분리하여 가독성을 개선했다. 기존에 존재하던 할당량(quota) 초과 가드 로직은 제거되었다. TypewriterFlow는 MutableStateFlow 대신 StringBuffer를 사용하도록 변경하여 문자열 결합 효율을 높였으며, CATCH_UP_DIVISOR 값을 32로 조정하여 실제 기기 체감에 맞춰 타이핑 방출 속도를 최적화했다. 테스트 코드에서는 불필요한 문자열 템플릿과 공백을 정리했다. --- .../java/com/kikidan/chat/ChatViewModel.kt | 102 ++++++++++-------- .../java/com/kikidan/chat/TypewriterFlow.kt | 40 +++---- .../com/kikidan/chat/ChatViewModelTest.kt | 7 +- 3 files changed, 70 insertions(+), 79 deletions(-) diff --git a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt index a77f899..216656c 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt @@ -86,10 +86,6 @@ class ChatViewModel // 전송 진입점이 여러 개이므로 가드를 여기 한 곳에만 둔다 (설계 2-7). private suspend fun Syntax.send(content: String) { if (state.streamingChatState !is StreamingChatState.Idle) return - if (state.quota?.let { it.remaining <= 0 } == true) { - postSideEffect(ChatSideEffect.ShowStreamingErrorMessage(QUOTA_EXHAUSTED_MESSAGE)) - return - } val conversationId = state.conversationId val placeholder = localUserMessage(content) @@ -107,30 +103,11 @@ class ChatViewModel try { sendChatMessage(conversationId, content) .transform { result -> - // 모든 실패를 예외 한 채널로 되돌린다 (설계 2-3). when (val event = result.getOrElse { throw it }) { is ChatStreamEvent.Start -> { - streamConversationId = event.conversationId - assistantMessageId = event.assistantMessageId - // transform 람다의 수신자는 FlowCollector이므로, - // reduce/state는 외부 SimpleSyntax 수신자로 해석된다 (설계 2, 주의 1). - reduce { - state.copy( - // 낙관적 메시지의 로컬 id를 서버가 준 진짜 id로 교체 (설계 2-6). - messages = - state.messages.map { msg -> - if (msg.id == placeholder.id) { - msg.copy( - id = event.userMessageId, - status = MessageStatus.COMPLETED, - ) - } else { - msg - } - }, - quota = event.quota, - ) - } + val result = onStreamingStart(placeholder, event) + streamConversationId = result.first + assistantMessageId = result.second } is ChatStreamEvent.Delta -> { @@ -158,25 +135,11 @@ class ChatViewModel } } - // typewriter가 완료된 = 버퍼가 다 비워진 시점 (설계 2-2). - reduce { - val currentStreamingState = state.streamingChatState - state.copy( - conversationId = streamConversationId, - messages = - if (currentStreamingState is StreamingChatState.Typing) { - state.messages + - assistantMessage( - id = assistantMessageId, - content = currentStreamingState.streamingText, - action = pendingAction, - ) - } else { - state.messages - }, - streamingChatState = StreamingChatState.Idle, - ) - } + onSteamingDone( + streamConversationId, + assistantMessageId, + pendingAction, + ) } catch (e: CancellationException) { throw e } catch (e: Throwable) { @@ -189,8 +152,53 @@ class ChatViewModel } } - private companion object { - const val QUOTA_EXHAUSTED_MESSAGE = "오늘 무료 채팅을 모두 사용했어요." + 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 + } + }, + 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 + + assistantMessage( + id = assistantMessageId, + content = currentStreamingState.streamingText, + action = pendingAction, + ) + } else { + state.messages + }, + streamingChatState = StreamingChatState.Idle, + ) + } } } diff --git a/feature/chat/src/main/java/com/kikidan/chat/TypewriterFlow.kt b/feature/chat/src/main/java/com/kikidan/chat/TypewriterFlow.kt index dc7846d..20fb6f0 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/TypewriterFlow.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/TypewriterFlow.kt @@ -2,52 +2,36 @@ package com.kikidan.chat import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -// 실기기에서 조정할 튜닝 노브. 체감 속도는 서버가 보내는 청크 크기에 좌우된다. -internal const val TYPING_TICK_MS = 16L // 60fps 프레임 간격 -private const val CATCH_UP_DIVISOR = 12 // 남은 글자의 1/12를 매 틱 추가 방출 - /** * 도착 속도(네트워크)와 표시 속도(화면)를 분리한다. - * - * 업스트림 청크를 버퍼에 쌓고 tick마다 조금씩 잘라 "지금까지 보여줄 전체 텍스트"를 방출한다. - * 백로그가 작으면 한 글자씩, 쌓이면 자동으로 빨라져 생성 종료 후 혼자 타이핑하는 지연을 막는다. - * 업스트림이 끝나도 버퍼가 다 빌 때까지 방출을 이어간 뒤 완료한다. - * - * channelFlow를 사용하는 이유: 컨슈머가 취소되면 프로듀서 스코프와 그 자식(upstream launch)이 - * 함께 취소돼 업스트림 자원이 정상 해제된다. + * 업스트림 청크를 버퍼에 쌓고 tick마다 조금씩 잘라 지금까지 보여줄 전체 텍스"를 방출한다. */ -internal fun Flow.typewriter(tickMillis: Long = TYPING_TICK_MS): Flow = +internal fun Flow.typewriter(tickMillis: Long = 16L): Flow = channelFlow { - val buffered = MutableStateFlow("") - val upstream = launch { this@typewriter.collect { chunk -> buffered.update { it + chunk } } } + val buffered = StringBuffer() + val upstream = launch { this@typewriter.collect { chunk -> buffered.append(chunk) } } var shown = 0 while (true) { - // isActive를 value보다 먼저 읽어야 한다. 순서를 바꾸면 - // "value를 읽은 직후 도착한 마지막 청크"를 못 보고 종료할 수 있다. val upstreamDone = !upstream.isActive - val full = buffered.value when { - shown < full.length -> { + shown < buffered.length -> { shown = - (shown + 1 + (full.length - shown) / CATCH_UP_DIVISOR) - .coerceAtMost(full.length) - // ponytail: 틱마다 substring이라 전체 O(n^2). 수천 자 답변까지는 무시 가능. - send(full.substring(0, shown)) + (((buffered.length - shown) / CATCH_UP_DIVISOR) + shown + 1) + .coerceAtMost(buffered.length) + send(buffered.substring(0, shown)) } upstreamDone -> { return@channelFlow } - - else -> { - Unit - } } delay(tickMillis) } } + +// 남은 글자의 1/32 를 매 틱 추가 방출, +// 약 0.35초에 남은 buffer의 절반을 채우는 속도, tickMillis 파라미터와 더불어 실기기에서 체크 후 조정 요망 +private const val CATCH_UP_DIVISOR = 32 diff --git a/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt b/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt index 4cfe23d..33620b2 100644 --- a/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt +++ b/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt @@ -145,10 +145,10 @@ class ChatViewModelTest { 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 + val currentChatState = typingStates[i].streamingChatState as StreamingChatState.Typing assertTrue( - "streamingText 단조 증가 실패: '${chatState}' → '${chatState}'", - currentChatState.streamingText.startsWith(chatState.streamingText), + "streamingText 단조 증가 실패: '$chatState' → '$chatState'", + currentChatState.streamingText.startsWith(chatState.streamingText), ) } } @@ -467,5 +467,4 @@ class ChatViewModelTest { action = null, createdAt = Instant.now(), ) - } From 41e7fd10f1d221d2e98deebd215fc48bdaf7f7d0 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Thu, 6 Aug 2026 16:27:21 +0900 Subject: [PATCH 07/11] =?UTF-8?q?test:=20ChatViewModelTest=20=EB=82=B4=20?= =?UTF-8?q?=ED=95=A0=EB=8B=B9=EB=9F=89=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EB=AA=A8=EB=8D=B8=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatSuggestion의 카테고리 인자가 String에서 ChatCategory 타입으로 변경됨에 따라 테스트 데이터를 수정한다. 잔여 할당량이 0일 때 메시지 전송을 차단하던 로직의 테스트 케이스를 삭제한다. TypewriterFlowTest에 ExperimentalCoroutinesApi 사용을 위한 @OptIn 설정을 추가한다. --- .../com/kikidan/chat/ChatViewModelTest.kt | 26 ++----------------- .../com/kikidan/chat/TypewriterFlowTest.kt | 2 ++ 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt b/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt index 33620b2..06acaec 100644 --- a/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt +++ b/feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt @@ -5,6 +5,7 @@ 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 @@ -370,29 +371,6 @@ class ChatViewModelTest { } } - @Test - fun `quota remaining = 0 에서 send 시 전송 없이 ShowStreamingErrorMessage 이벤트가 방출된다`() = - runTest { - val exhaustedQuota = ChatQuota(used = 10, limit = 10) // remaining = 0 - val fakeRepo = - FakeChatRepository().apply { - chatEntryResult = Result.success(ChatEntry("", emptyList(), exhaustedQuota)) - } - val vm = viewModel(fakeRepo) - - vm.test(this) { - containerHost.load(null) - awaitState() // quota 설정 - val s2 = awaitState() // isLoading=false - assertEquals(0, s2.quota!!.remaining) - - containerHost.onSuggestionClick("안녕") - val se = awaitSideEffect() - assertTrue(se is ChatSideEffect.ShowStreamingErrorMessage) - assertEquals(0, fakeRepo.sendCallCount) - } - } - @Test fun `onInputChange에 501자 입력 시 input length가 500으로 제한된다`() = runTest { @@ -446,7 +424,7 @@ class ChatViewModelTest { private val defaultEntry = ChatEntry( greeting = "안녕하세요", - suggestions = listOf(ChatSuggestion("😊", "label", "seed", "cat")), + suggestions = listOf(ChatSuggestion("😊", "label", "seed", ChatCategory.LOVE)), quota = ChatQuota(used = 1, limit = 10), ) diff --git a/feature/chat/src/test/java/com/kikidan/chat/TypewriterFlowTest.kt b/feature/chat/src/test/java/com/kikidan/chat/TypewriterFlowTest.kt index 1e5a60d..ffe9ce0 100644 --- a/feature/chat/src/test/java/com/kikidan/chat/TypewriterFlowTest.kt +++ b/feature/chat/src/test/java/com/kikidan/chat/TypewriterFlowTest.kt @@ -1,5 +1,6 @@ package com.kikidan.chat +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow @@ -11,6 +12,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class TypewriterFlowTest { @Test fun `단일 청크를 받으면 여러 번 나눠 방출하고 마지막 값이 전체 텍스트와 같다`() = From 93ebe37e82047717977da31100f5990150d2c769 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Thu, 6 Aug 2026 22:44:58 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=A0=84=EC=86=A1=20=EC=8B=9C=20=EC=95=9E?= =?UTF-8?q?=EB=92=A4=20=EA=B3=B5=EB=B0=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메시지 플레이스홀더를 생성할 때 `trim()`을 호출하여 입력된 텍스트의 불필요한 앞뒤 공백을 제거하도록 수정한다. --- feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt index 216656c..f6267ec 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt @@ -88,7 +88,7 @@ class ChatViewModel if (state.streamingChatState !is StreamingChatState.Idle) return val conversationId = state.conversationId - val placeholder = localUserMessage(content) + val placeholder = localUserMessage(content.trim()) reduce { state.copy( messages = state.messages + placeholder, From 7476a52f1f7651140cbf672b1583f6342674d33b Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 7 Aug 2026 01:57:22 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20feature:chat=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=97=90=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=ED=94=8C=EB=9F=AC?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit todakun.feature 플러그인을 사용하여 개별 모듈에서 반복되는 빌드 설정을 통합 관리한다. 이로 인해 중복되는 Android SDK 버전 설정, 컴파일 옵션 및 Hilt, Orbit, Compose 관련 공통 의존성 선언을 삭제하여 빌드 스크립트를 간소화한다. --- feature/chat/build.gradle.kts | 27 +-------------------------- feature/chat/consumer-rules.pro | 0 gradle/libs.versions.toml | 1 + 3 files changed, 2 insertions(+), 26 deletions(-) create mode 100644 feature/chat/consumer-rules.pro diff --git a/feature/chat/build.gradle.kts b/feature/chat/build.gradle.kts index 39db02a..36f136e 100644 --- a/feature/chat/build.gradle.kts +++ b/feature/chat/build.gradle.kts @@ -1,45 +1,20 @@ plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.compose) - alias(libs.plugins.hilt) - alias(libs.plugins.ksp) + alias(libs.plugins.todakun.feature) } android { namespace = "com.kikidan.chat" - compileSdk = 37 defaultConfig { - minSdk = 26 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } } dependencies { - implementation(projects.core.domain) - implementation(projects.core.designsystem) - - implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.ui.tooling.preview) - debugImplementation(libs.androidx.compose.ui.tooling) - implementation(libs.androidx.core.ktx) implementation(libs.kotlinx.coroutines.core) - implementation(libs.hilt.android) - ksp(libs.hilt.compiler) - implementation(libs.hilt.navigation.compose) - - implementation(libs.orbit.core) - implementation(libs.orbit.viewmodel) - implementation(libs.orbit.compose) - testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.orbit.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/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" } From 15b47c971e3cb6626b7b11376fd863df67ef6b74 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Sat, 8 Aug 2026 14:26:06 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat:=20ChatState=EC=9D=98=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=95=84=EB=93=9C=EC=97=90=20PersistentLi?= =?UTF-8?q?st=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit suggestions와 messages의 타입을 List 대신 PersistentList로 변경한다. Jetpack Compose에서 일반 List 인터페이스는 안정성(Stability)을 보장할 수 없어 불필요한 리컴포지션이 발생할 수 있으므로, 명시적으로 불변 컬렉션을 사용하여 성능을 최적화한다. Co-Authored-By: Claude --- .../chat/src/main/java/com/kikidan/chat/model/ChatState.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 8e07aa1..ad99615 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/model/ChatState.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/model/ChatState.kt @@ -3,14 +3,16 @@ 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: List = emptyList(), + val suggestions: PersistentList = persistentListOf(), val quota: ChatQuota? = null, - val messages: List = emptyList(), + val messages: PersistentList = persistentListOf(), val input: String = "", val streamingChatState: StreamingChatState = StreamingChatState.Idle, ) From e690c2a525a06c0a0f23ac3bc71600cabd977252 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Sat, 8 Aug 2026 15:04:57 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20ChatState=EC=9D=98=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=83=80=EC=9E=85=EC=9D=84=20Imm?= =?UTF-8?q?utable=20Collection=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit suggestions와 messages를 PersistentList로 변경하여 UI 상태의 안정성을 확보하고 Compose 리컴포지션 최적화를 도모한다. 기존의 standard List 대신 toPersistentList()와 persistentListOf()를 사용하도록 전환한다. --- .../java/com/kikidan/chat/ChatViewModel.kt | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt index f6267ec..1ce5dc1 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt @@ -13,6 +13,8 @@ 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 @@ -42,7 +44,7 @@ class ChatViewModel reduce { state.copy( greeting = entry.greeting, - suggestions = entry.suggestions, + suggestions = entry.suggestions.toPersistentList(), quota = entry.quota, ) } @@ -50,7 +52,7 @@ class ChatViewModel if (conversationId != null) { getConversationDetail(conversationId) - .onSuccess { reduce { state.copy(messages = it.messages) } } + .onSuccess { reduce { state.copy(messages = it.messages.toPersistentList()) } } .onFailure { postSideEffect(ChatSideEffect.Error(it)) } } @@ -76,7 +78,7 @@ class ChatViewModel reduce { state.copy( conversationId = null, - messages = emptyList(), + messages = persistentListOf(), streamingChatState = StreamingChatState.Idle, input = "", ) @@ -91,7 +93,7 @@ class ChatViewModel val placeholder = localUserMessage(content.trim()) reduce { state.copy( - messages = state.messages + placeholder, + messages = state.messages.adding(placeholder), streamingChatState = StreamingChatState.Thinking, ) } @@ -160,16 +162,17 @@ class ChatViewModel state.copy( // 낙관적 메시지의 로컬 id를 서버가 준 진짜 id로 교체. messages = - state.messages.map { msg -> - if (msg.id == placeholder.id) { - msg.copy( - id = event.userMessageId, - status = MessageStatus.COMPLETED, - ) - } else { - msg - } - }, + state.messages + .map { msg -> + if (msg.id == placeholder.id) { + msg.copy( + id = event.userMessageId, + status = MessageStatus.COMPLETED, + ) + } else { + msg + } + }.toPersistentList(), quota = event.quota, ) } @@ -187,12 +190,13 @@ class ChatViewModel conversationId = streamConversationId, messages = if (currentStreamingState is StreamingChatState.Typing) { - state.messages + + state.messages.adding( assistantMessage( id = assistantMessageId, content = currentStreamingState.streamingText, action = pendingAction, - ) + ), + ) } else { state.messages },