diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index da4e69e..f487d27 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -15,4 +15,6 @@ kotlin { dependencies { implementation(libs.javax.inject) api(libs.kotlinx.coroutines.core) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/core/domain/src/main/java/com/kikidan/domain/model/chat/ChatAction.kt b/core/domain/src/main/java/com/kikidan/domain/model/chat/ChatAction.kt new file mode 100644 index 0000000..0e91be0 --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/model/chat/ChatAction.kt @@ -0,0 +1,14 @@ +package com.kikidan.domain.model.chat + +import java.time.LocalDate + +data class ChatAction( + val type: ChatActionType, + val label: String, + val category: String, + val date: LocalDate?, +) + +enum class ChatActionType { + CALENDAR_ADD, +} diff --git a/core/domain/src/main/java/com/kikidan/domain/model/chat/ChatEntry.kt b/core/domain/src/main/java/com/kikidan/domain/model/chat/ChatEntry.kt new file mode 100644 index 0000000..8bb438e --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/model/chat/ChatEntry.kt @@ -0,0 +1,29 @@ +package com.kikidan.domain.model.chat + +data class ChatEntry( + val greeting: String, + val suggestions: List, + val quota: ChatQuota, +) + +data class ChatSuggestion( + val emoji: String, + val label: String, + val seedPrompt: String, + val category: ChatCategory?, +) + +data class ChatQuota( + val used: Int, + val limit: Int, +) { + val remaining: Int get() = (limit - used).coerceAtLeast(0) +} + +enum class ChatCategory { + RELATIONSHIP, + LOVE, + ACHIEVEMENT, + MONEY, + HEALTH, +} diff --git a/core/domain/src/main/java/com/kikidan/domain/model/chat/ChatMessage.kt b/core/domain/src/main/java/com/kikidan/domain/model/chat/ChatMessage.kt new file mode 100644 index 0000000..b3a137f --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/model/chat/ChatMessage.kt @@ -0,0 +1,23 @@ +package com.kikidan.domain.model.chat + +import java.time.Instant + +data class ChatMessage( + val id: String, + val role: MessageRole, + val content: String, + val status: MessageStatus, + val action: ChatAction?, + val createdAt: Instant, +) + +enum class MessageRole { + USER, + ASSISTANT, +} + +enum class MessageStatus { + GENERATING, + COMPLETED, + FAILED, +} diff --git a/core/domain/src/main/java/com/kikidan/domain/model/chat/ChatStreamEvent.kt b/core/domain/src/main/java/com/kikidan/domain/model/chat/ChatStreamEvent.kt new file mode 100644 index 0000000..b959fc7 --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/model/chat/ChatStreamEvent.kt @@ -0,0 +1,27 @@ +package com.kikidan.domain.model.chat + +sealed interface ChatStreamEvent { + data class Start( + val conversationId: String, + val userMessageId: String, + val assistantMessageId: String, + val quota: ChatQuota, + ) : ChatStreamEvent + + data class Delta( + val text: String, + ) : ChatStreamEvent + + data class Action( + val action: ChatAction, + ) : ChatStreamEvent + + data class Done( + val assistantMessageId: String, + ) : ChatStreamEvent + + data class Error( + val code: String, + val message: String, + ) : ChatStreamEvent +} diff --git a/core/domain/src/main/java/com/kikidan/domain/model/chat/Conversation.kt b/core/domain/src/main/java/com/kikidan/domain/model/chat/Conversation.kt new file mode 100644 index 0000000..128c5a2 --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/model/chat/Conversation.kt @@ -0,0 +1,16 @@ +package com.kikidan.domain.model.chat + +import java.time.Instant + +data class Conversation( + val id: String, + val title: String, + val messages: List, +) + +data class ConversationSummary( + val id: String, + val title: String, + val lastMessageAt: Instant, + val unread: Boolean, +) diff --git a/core/domain/src/main/java/com/kikidan/domain/repository/ChatRepository.kt b/core/domain/src/main/java/com/kikidan/domain/repository/ChatRepository.kt new file mode 100644 index 0000000..1e9a49d --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/repository/ChatRepository.kt @@ -0,0 +1,23 @@ +package com.kikidan.domain.repository + +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 kotlinx.coroutines.flow.Flow + +interface ChatRepository { + suspend fun getChatEntry(): Result + + // conversationId가 null이면 새 대화를 시작한다. 새 대화의 id는 첫 Start 이벤트로 내려온다. + fun sendMessage( + conversationId: String?, + content: String, + ): Flow> + + suspend fun getConversations(): Result> + + suspend fun getConversationDetail(conversationId: String): Result + + suspend fun deleteConversation(conversationId: String): Result +} diff --git a/core/domain/src/main/java/com/kikidan/domain/usecase/DeleteConversationUseCase.kt b/core/domain/src/main/java/com/kikidan/domain/usecase/DeleteConversationUseCase.kt new file mode 100644 index 0000000..e3dd03a --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/usecase/DeleteConversationUseCase.kt @@ -0,0 +1,13 @@ +package com.kikidan.domain.usecase + +import com.kikidan.domain.repository.ChatRepository +import javax.inject.Inject + +class DeleteConversationUseCase + @Inject + constructor( + private val chatRepository: ChatRepository, + ) { + suspend operator fun invoke(conversationId: String): Result = + chatRepository.deleteConversation(conversationId) + } diff --git a/core/domain/src/main/java/com/kikidan/domain/usecase/GetChatEntryUseCase.kt b/core/domain/src/main/java/com/kikidan/domain/usecase/GetChatEntryUseCase.kt new file mode 100644 index 0000000..9c5cea7 --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/usecase/GetChatEntryUseCase.kt @@ -0,0 +1,13 @@ +package com.kikidan.domain.usecase + +import com.kikidan.domain.model.chat.ChatEntry +import com.kikidan.domain.repository.ChatRepository +import javax.inject.Inject + +class GetChatEntryUseCase + @Inject + constructor( + private val chatRepository: ChatRepository, + ) { + suspend operator fun invoke(): Result = chatRepository.getChatEntry() + } diff --git a/core/domain/src/main/java/com/kikidan/domain/usecase/GetConversationDetailUseCase.kt b/core/domain/src/main/java/com/kikidan/domain/usecase/GetConversationDetailUseCase.kt new file mode 100644 index 0000000..f1a9f3e --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/usecase/GetConversationDetailUseCase.kt @@ -0,0 +1,14 @@ +package com.kikidan.domain.usecase + +import com.kikidan.domain.model.chat.Conversation +import com.kikidan.domain.repository.ChatRepository +import javax.inject.Inject + +class GetConversationDetailUseCase + @Inject + constructor( + private val chatRepository: ChatRepository, + ) { + suspend operator fun invoke(conversationId: String): Result = + chatRepository.getConversationDetail(conversationId) + } diff --git a/core/domain/src/main/java/com/kikidan/domain/usecase/GetConversationsUseCase.kt b/core/domain/src/main/java/com/kikidan/domain/usecase/GetConversationsUseCase.kt new file mode 100644 index 0000000..4878062 --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/usecase/GetConversationsUseCase.kt @@ -0,0 +1,13 @@ +package com.kikidan.domain.usecase + +import com.kikidan.domain.model.chat.ConversationSummary +import com.kikidan.domain.repository.ChatRepository +import javax.inject.Inject + +class GetConversationsUseCase + @Inject + constructor( + private val chatRepository: ChatRepository, + ) { + suspend operator fun invoke(): Result> = chatRepository.getConversations() + } diff --git a/core/domain/src/main/java/com/kikidan/domain/usecase/SendChatMessageUseCase.kt b/core/domain/src/main/java/com/kikidan/domain/usecase/SendChatMessageUseCase.kt new file mode 100644 index 0000000..f9c41f6 --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/usecase/SendChatMessageUseCase.kt @@ -0,0 +1,33 @@ +package com.kikidan.domain.usecase + +import com.kikidan.domain.model.chat.ChatStreamEvent +import com.kikidan.domain.repository.ChatRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +class SendChatMessageUseCase + @Inject + constructor( + private val chatRepository: ChatRepository, + ) { + operator fun invoke( + conversationId: String?, + content: String, + ): Flow> { + val trimmed = content.trim() + if (trimmed.isEmpty() || trimmed.length > MAX_CONTENT_LENGTH) { + return flowOf( + Result.failure( + IllegalArgumentException("메시지는 1자 이상 ${MAX_CONTENT_LENGTH}자 이하여야 합니다."), + ), + ) + } + return chatRepository.sendMessage(conversationId, trimmed) + } + + companion object { + // presentation의 입력창 maxLength도 이 상수를 참조한다. + const val MAX_CONTENT_LENGTH = 500 + } + } diff --git a/core/domain/src/test/java/com/kikidan/domain/fake/FakeChatRepository.kt b/core/domain/src/test/java/com/kikidan/domain/fake/FakeChatRepository.kt new file mode 100644 index 0000000..d443c6c --- /dev/null +++ b/core/domain/src/test/java/com/kikidan/domain/fake/FakeChatRepository.kt @@ -0,0 +1,36 @@ +package com.kikidan.domain.fake + +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 lastSentConversationId: String? = null + var lastSentContent: String? = null + var sendMessageCallCount: Int = 0 + var streamEvents: List> = emptyList() + + override suspend fun getChatEntry(): Result = Result.failure(NotImplementedError()) + + override fun sendMessage( + conversationId: String?, + content: String, + ): Flow> { + lastSentConversationId = conversationId + lastSentContent = content + sendMessageCallCount++ + return streamEvents.asFlow() + } + + override suspend fun getConversations(): Result> = Result.failure(NotImplementedError()) + + override suspend fun getConversationDetail(conversationId: String): Result = + Result.failure(NotImplementedError()) + + override suspend fun deleteConversation(conversationId: String): Result = + Result.failure(NotImplementedError()) +} diff --git a/core/domain/src/test/java/com/kikidan/domain/usecase/SendChatMessageUseCaseTest.kt b/core/domain/src/test/java/com/kikidan/domain/usecase/SendChatMessageUseCaseTest.kt new file mode 100644 index 0000000..e933c7b --- /dev/null +++ b/core/domain/src/test/java/com/kikidan/domain/usecase/SendChatMessageUseCaseTest.kt @@ -0,0 +1,76 @@ +package com.kikidan.domain.usecase + +import com.kikidan.domain.fake.FakeChatRepository +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class SendChatMessageUseCaseTest { + private lateinit var fakeRepository: FakeChatRepository + private lateinit var useCase: SendChatMessageUseCase + + @Before + fun setUp() { + fakeRepository = FakeChatRepository() + useCase = SendChatMessageUseCase(fakeRepository) + } + + @Test + fun `정상 content 전송 시 Repository Flow가 그대로 전달된다`() = + runTest { + val results = useCase(conversationId = null, content = "안녕하세요").toList() + assertEquals(1, fakeRepository.sendMessageCallCount) + assertEquals("안녕하세요", fakeRepository.lastSentContent) + } + + @Test + fun `content가 빈 문자열이면 Repository 호출 없이 failure를 방출한다`() = + runTest { + val results = useCase(conversationId = null, content = "").toList() + assertEquals(0, fakeRepository.sendMessageCallCount) + assertEquals(1, results.size) + assertTrue(results[0].isFailure) + assertTrue(results[0].exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun `content가 공백만이면 Repository 호출 없이 failure를 방출한다`() = + runTest { + val results = useCase(conversationId = null, content = " ").toList() + assertEquals(0, fakeRepository.sendMessageCallCount) + assertEquals(1, results.size) + assertTrue(results[0].isFailure) + assertTrue(results[0].exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun `content가 501자이면 Repository 호출 없이 failure를 방출한다`() = + runTest { + val content = "a".repeat(SendChatMessageUseCase.MAX_CONTENT_LENGTH + 1) + val results = useCase(conversationId = null, content = content).toList() + assertEquals(0, fakeRepository.sendMessageCallCount) + assertEquals(1, results.size) + assertTrue(results[0].isFailure) + assertTrue(results[0].exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun `content 앞뒤 공백은 trim되어 Repository에 전달된다`() = + runTest { + useCase(conversationId = null, content = " 안녕 ").toList() + assertEquals(1, fakeRepository.sendMessageCallCount) + assertEquals("안녕", fakeRepository.lastSentContent) + } + + @Test + fun `content가 정확히 500자이면 정상 통과한다`() = + runTest { + val content = "a".repeat(SendChatMessageUseCase.MAX_CONTENT_LENGTH) + useCase(conversationId = null, content = content).toList() + assertEquals(1, fakeRepository.sendMessageCallCount) + assertEquals(content, fakeRepository.lastSentContent) + } +}