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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core/domain/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ kotlin {
dependencies {
implementation(libs.javax.inject)
api(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.kikidan.domain.model.chat

data class ChatEntry(
val greeting: String,
val suggestions: List<ChatSuggestion>,
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,
}
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<ChatMessage>,
)

data class ConversationSummary(
val id: String,
val title: String,
val lastMessageAt: Instant,
val unread: Boolean,
)
Original file line number Diff line number Diff line change
@@ -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<ChatEntry>

// conversationId가 null이면 새 대화를 시작한다. 새 대화의 id는 첫 Start 이벤트로 내려온다.
fun sendMessage(
conversationId: String?,
content: String,
): Flow<Result<ChatStreamEvent>>

suspend fun getConversations(): Result<List<ConversationSummary>>

suspend fun getConversationDetail(conversationId: String): Result<Conversation>

suspend fun deleteConversation(conversationId: String): Result<Unit>
}
Original file line number Diff line number Diff line change
@@ -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<Unit> =
chatRepository.deleteConversation(conversationId)
}
Original file line number Diff line number Diff line change
@@ -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<ChatEntry> = chatRepository.getChatEntry()
}
Original file line number Diff line number Diff line change
@@ -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<Conversation> =
chatRepository.getConversationDetail(conversationId)
}
Original file line number Diff line number Diff line change
@@ -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<List<ConversationSummary>> = chatRepository.getConversations()
}
Original file line number Diff line number Diff line change
@@ -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<Result<ChatStreamEvent>> {
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
}
}
Original file line number Diff line number Diff line change
@@ -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<Result<ChatStreamEvent>> = emptyList()

override suspend fun getChatEntry(): Result<ChatEntry> = Result.failure(NotImplementedError())

override fun sendMessage(
conversationId: String?,
content: String,
): Flow<Result<ChatStreamEvent>> {
lastSentConversationId = conversationId
lastSentContent = content
sendMessageCallCount++
return streamEvents.asFlow()
}

override suspend fun getConversations(): Result<List<ConversationSummary>> = Result.failure(NotImplementedError())

override suspend fun getConversationDetail(conversationId: String): Result<Conversation> =
Result.failure(NotImplementedError())

override suspend fun deleteConversation(conversationId: String): Result<Unit> =
Result.failure(NotImplementedError())
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading