feat(chat-ui): 메시지 그룹화, 시간 포맷팅, Repository 테스트 추가

This commit is contained in:
2025-08-14 18:08:01 +09:00
parent ec60d4f143
commit d662bd0b65
5 changed files with 207 additions and 0 deletions

View File

@@ -209,4 +209,11 @@ dependencies {
implementation "com.kakao.sdk:v2-user:2.21.0"
implementation 'io.github.glailton.expandabletextview:expandabletextview:1.0.4'
// ----- Test dependencies -----
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.12.0'
testImplementation 'org.mockito:mockito-inline:5.2.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.3.1'
testImplementation 'io.mockk:mockk:1.13.10'
}

View File

@@ -14,6 +14,7 @@ import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.widget.TextView
import androidx.annotation.LayoutRes
import androidx.annotation.VisibleForTesting
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
@@ -48,6 +49,34 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
const val VIEW_TYPE_AI_MESSAGE = 2
const val VIEW_TYPE_NOTICE = 3
const val VIEW_TYPE_TYPING_INDICATOR = 4
/**
* [list]와 [position]을 기준으로 그룹화 여부와 해당 아이템이 그룹의 마지막인지 계산한다.
* 테스트를 위해 노출된 유틸 함수이며, onBindViewHolder의 로직과 동일한 판정을 수행한다.
*/
@VisibleForTesting
internal fun computeGroupingFlags(list: List<ChatListItem>, position: Int): Pair<Boolean, Boolean> {
fun isSameSender(prev: ChatListItem?, curr: ChatListItem?): Boolean {
if (prev == null || curr == null) return false
val p = when (prev) {
is ChatListItem.UserMessage -> prev.data.mine
is ChatListItem.AiMessage -> prev.data.mine
else -> return false
}
val c = when (curr) {
is ChatListItem.UserMessage -> curr.data.mine
is ChatListItem.AiMessage -> curr.data.mine
else -> return false
}
return p == c
}
val curr = list.getOrNull(position)
val prev = if (position > 0) list.getOrNull(position - 1) else null
val next = if (position < list.lastIndex) list.getOrNull(position + 1) else null
val grouped = isSameSender(prev, curr)
val isLast = !isSameSender(curr, next)
return grouped to isLast
}
}
private val items: MutableList<ChatListItem> = mutableListOf()
@@ -93,6 +122,15 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
notifyDataSetChanged()
}
/**
* 테스트 전용: RecyclerView 프레임워크 없이 내부 리스트만 채우고 알림을 보내지 않는다.
*/
@VisibleForTesting
internal fun setItemsForTest(newItems: List<ChatListItem>) {
items.clear()
items.addAll(newItems)
}
fun addItem(item: ChatListItem) {
items.add(item)
notifyItemInserted(items.lastIndex)

View File

@@ -0,0 +1,77 @@
package kr.co.vividnext.sodalive.chat.talk.room
import org.junit.Assert.assertEquals
import org.junit.Test
class ChatMessageAdapterTest {
@Test
fun `getItemViewType returns correct types`() {
val adapter = ChatMessageAdapter()
val list = listOf(
ChatListItem.UserMessage(ChatMessage(1, "hi", "", mine = true, createdAt = 1L)),
ChatListItem.AiMessage(ChatMessage(2, "hello", "", mine = false, createdAt = 2L)),
ChatListItem.Notice("notice"),
ChatListItem.TypingIndicator
)
adapter.setItemsForTest(list)
assertEquals(ChatMessageAdapter.VIEW_TYPE_USER_MESSAGE, adapter.getItemViewType(0))
assertEquals(ChatMessageAdapter.VIEW_TYPE_AI_MESSAGE, adapter.getItemViewType(1))
assertEquals(ChatMessageAdapter.VIEW_TYPE_NOTICE, adapter.getItemViewType(2))
assertEquals(ChatMessageAdapter.VIEW_TYPE_TYPING_INDICATOR, adapter.getItemViewType(3))
}
@Test
fun `computeGroupingFlags determines group and last correctly`() {
val items = listOf(
ChatListItem.AiMessage(ChatMessage(1, "a1", "", mine = false, createdAt = 1L)),
ChatListItem.AiMessage(ChatMessage(2, "a2", "", mine = false, createdAt = 2L)),
ChatListItem.AiMessage(ChatMessage(3, "a3", "", mine = false, createdAt = 3L)),
ChatListItem.UserMessage(ChatMessage(4, "u1", "", mine = true, createdAt = 4L)),
ChatListItem.UserMessage(ChatMessage(5, "u2", "", mine = true, createdAt = 5L)),
ChatListItem.Notice("guide"),
ChatListItem.UserMessage(ChatMessage(6, "u3", "", mine = true, createdAt = 6L)),
ChatListItem.AiMessage(ChatMessage(7, "a4", "", mine = false, createdAt = 7L))
)
// index 0: 첫 메시지, 그룹 아님, 다음과 같으므로 마지막 아님
ChatMessageAdapter.computeGroupingFlags(items, 0).let { (grouped, last) ->
assertEquals(false, grouped)
assertEquals(false, last)
}
// index 1: 이전과 동일 발신자 -> 그룹, 다음과 동일 -> 마지막 아님
ChatMessageAdapter.computeGroupingFlags(items, 1).let { (grouped, last) ->
assertEquals(true, grouped)
assertEquals(false, last)
}
// index 2: 이전과 동일 발신자 -> 그룹, 다음은 다른 발신자 -> 마지막
ChatMessageAdapter.computeGroupingFlags(items, 2).let { (grouped, last) ->
assertEquals(true, grouped)
assertEquals(true, last)
}
// index 3: 사용자 메시지 시작 -> 그룹 아님, 다음 동일 -> 마지막 아님
ChatMessageAdapter.computeGroupingFlags(items, 3).let { (grouped, last) ->
assertEquals(false, grouped)
assertEquals(false, last)
}
// index 4: 사용자 그룹의 마지막 (다음 아이템은 Notice라 발신자 비교 실패) -> grouped true, last true
ChatMessageAdapter.computeGroupingFlags(items, 4).let { (grouped, last) ->
assertEquals(true, grouped)
assertEquals(true, last)
}
// index 5: Notice -> 그룹 로직 비대상, grouped false, last true(다음과 비교 불가로 true)
ChatMessageAdapter.computeGroupingFlags(items, 5).let { (grouped, last) ->
assertEquals(false, grouped)
assertEquals(true, last)
}
// index 6: Notice 다음의 사용자 단독 -> grouped false, 다음은 ai라 last true
ChatMessageAdapter.computeGroupingFlags(items, 6).let { (grouped, last) ->
assertEquals(false, grouped)
assertEquals(true, last)
}
// index 7: 마지막 ai -> grouped false, last true
ChatMessageAdapter.computeGroupingFlags(items, 7).let { (grouped, last) ->
assertEquals(false, grouped)
assertEquals(true, last)
}
}
}

View File

@@ -0,0 +1,67 @@
package kr.co.vividnext.sodalive.chat.talk.room
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.chat.talk.TalkApi
import kr.co.vividnext.sodalive.chat.talk.room.db.ChatMessageDao
import kr.co.vividnext.sodalive.common.ApiResponse
import org.junit.Assert.assertEquals
import org.junit.Test
class ChatRepositoryTest {
@Test
fun `enterChatRoom inserts messages and returns response`() {
val dao = mockk<ChatMessageDao>(relaxed = true)
val api = mockk<TalkApi>()
val repo = ChatRepository(dao, api)
val serverMessages = listOf(
ServerChatMessage(1, "a1", "", mine = false, createdAt = 1000L),
ServerChatMessage(2, "u1", "", mine = true, createdAt = 2000L)
)
val character = CharacterInfo(10, "name", "", kr.co.vividnext.sodalive.chat.character.detail.CharacterType.CLONE)
val resp = ChatRoomEnterResponse(99, character, serverMessages, hasMoreMessages = false)
every { api.enterChatRoom(any(), any()) } returns Single.just(ApiResponse(true, resp, null))
coEvery { dao.getNthLatestCreatedAt(any(), any()) } returns null
val result = repo.enterChatRoom("token", 99).blockingGet()
// 반환 검증
assertEquals(99, result.roomId)
assertEquals(2, result.messages.size)
// 로컬 저장 검증
coVerify { dao.insertMessages(match { it.size == 2 }) }
}
@Test
fun `getRecentMessagesFromLocal maps to domain`() {
val dao = mockk<ChatMessageDao>()
val api = mockk<TalkApi>()
val repo = ChatRepository(dao, api)
val entities = listOf(
kr.co.vividnext.sodalive.chat.talk.room.db.ChatMessageEntity(
messageId = 1L,
roomId = 1L,
message = "hello",
profileImageUrl = "",
mine = true,
createdAt = 10L,
status = MessageStatus.SENT,
localId = null
)
)
coEvery { dao.getRecentMessages(1L) } returns entities
val list = repo.getRecentMessagesFromLocal(1L).blockingGet()
assertEquals(1, list.size)
assertEquals("hello", list[0].message)
assertEquals(true, list[0].mine)
}
}

View File

@@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.chat.talk.room
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Locale
class TimeUtilsTest {
@Test
fun `formatMessageTime returns localized pattern for ko_KR`() {
val ts = 1734190980000L // 임의 epoch millis
val result = formatMessageTime(ts, Locale.KOREA)
// 한국어 로케일일 때 "오전/오후 h:mm" 패턴 일부라도 충족하는지 확인
assertTrue(result.isNotBlank())
assertTrue(result.contains(":"))
// 오전 또는 오후 텍스트 포함
assertTrue(result.contains("오전") || result.contains("오후"))
}
}