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