feat(chat-ui): 메시지 그룹화, 시간 포맷팅, Repository 테스트 추가
This commit is contained in:
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("오후"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user