feat(chat): DM 채팅 메시지 매퍼를 추가한다

This commit is contained in:
2026-06-10 17:39:52 +09:00
parent e1ae7df0ee
commit 630f84c3e5
3 changed files with 153 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
package kr.co.vividnext.sodalive.v2.main.chat.dm.model
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse
private const val MESSAGE_TYPE_TEXT = "TEXT"
fun DmChatMessageResponse.toUiItem(): DmChatMessageUiItem? {
if (!messageType.equals(MESSAGE_TYPE_TEXT, ignoreCase = true) || textMessage == null) return null
return DmChatMessageUiItem(
messageId = messageId,
localId = null,
mine = mine,
textMessage = textMessage,
senderNickname = senderNickname,
senderProfileImageUrl = senderProfileImageUrl,
createdAt = createdAt,
status = DmChatMessageStatus.SENT
)
}
fun List<DmChatMessageResponse>.toUiItems(): List<DmChatMessageUiItem> = mapNotNull { it.toUiItem() }
/** Sorts messages by server creation time, then by messageId for same-time messages. */
fun List<DmChatMessageUiItem>.sortByCreatedAtAndMessageId(): List<DmChatMessageUiItem> =
sortedWith(compareBy<DmChatMessageUiItem> { it.createdAt }.thenBy { it.messageId ?: Long.MAX_VALUE })
/** Merges new messages while keeping the first-arrived item when server messageId duplicates. */
fun List<DmChatMessageUiItem>.mergeByMessageId(
incoming: List<DmChatMessageUiItem>
): List<DmChatMessageUiItem> {
val seenMessageIds = mapNotNull { it.messageId }.toMutableSet()
val newItems = incoming.filter { item ->
val messageId = item.messageId
messageId == null || seenMessageIds.add(messageId)
}
return (this + newItems).sortByCreatedAtAndMessageId()
}

View File

@@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.v2.main.chat.dm.model
enum class DmChatMessageStatus {
SENDING,
SENT,
FAILED
}
data class DmChatMessageUiItem(
val messageId: Long?,
val localId: String?,
val mine: Boolean,
val textMessage: String,
val senderNickname: String,
val senderProfileImageUrl: String,
val createdAt: Long,
val status: DmChatMessageStatus
)

View File

@@ -0,0 +1,97 @@
package kr.co.vividnext.sodalive.v2.main.chat.dm
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageStatus
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.mergeByMessageId
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.sortByCreatedAtAndMessageId
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItem
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItems
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DmChatMapperTest {
@Test
fun `TEXT 메시지는 전송 완료 UI item으로 매핑된다`() {
val item = response(messageType = "TEXT", textMessage = "안녕하세요").toUiItem()
requireNotNull(item)
assertEquals(10L, item.messageId)
assertNull(item.localId)
assertEquals(true, item.mine)
assertEquals("안녕하세요", item.textMessage)
assertEquals("크리에이터", item.senderNickname)
assertEquals("https://example.com/profile.png", item.senderProfileImageUrl)
assertEquals(1000L, item.createdAt)
assertEquals(DmChatMessageStatus.SENT, item.status)
}
@Test
fun `messageType은 대소문자를 무시하고 TEXT로 매핑된다`() {
val item = response(messageType = "text", textMessage = "소문자 타입").toUiItem()
requireNotNull(item)
assertEquals("소문자 타입", item.textMessage)
}
@Test
fun `TEXT가 아니거나 textMessage가 null이면 UI item에서 제외된다`() {
val voice = response(messageType = "VOICE", textMessage = null, voiceMessageUrl = "https://example.com/voice.m4a")
val nullText = response(messageType = "TEXT", textMessage = null)
assertNull(voice.toUiItem())
assertNull(nullText.toUiItem())
assertEquals(emptyList<Any>(), listOf(voice, nullText).toUiItems())
}
@Test
fun `메시지는 createdAt 이후 messageId 오름차순으로 정렬된다`() {
val items = listOf(
response(messageId = 3L, createdAt = 2000L),
response(messageId = 2L, createdAt = 1000L),
response(messageId = 1L, createdAt = 1000L)
).toUiItems().sortByCreatedAtAndMessageId()
assertEquals(listOf(1L, 2L, 3L), items.map { it.messageId })
}
@Test
fun `messageId가 중복된 서버 메시지는 병합 시 기존 item을 유지한다`() {
val existing = listOf(
response(messageId = 1L, textMessage = "기존").toUiItem(),
response(messageId = 2L, textMessage = "둘째").toUiItem()
).filterNotNull()
val incoming = listOf(
response(messageId = 1L, textMessage = "중복 신규").toUiItem(),
response(messageId = 3L, textMessage = "셋째").toUiItem()
).filterNotNull()
val merged = existing.mergeByMessageId(incoming)
assertEquals(listOf(1L, 2L, 3L), merged.map { it.messageId })
assertEquals("기존", merged.first { it.messageId == 1L }.textMessage)
}
private fun response(
messageId: Long = 10L,
messageType: String = "TEXT",
mine: Boolean = true,
createdAt: Long = 1000L,
textMessage: String? = "메시지",
voiceMessageUrl: String? = null,
senderId: Long = 20L,
senderNickname: String = "크리에이터",
senderProfileImageUrl: String = "https://example.com/profile.png"
) = DmChatMessageResponse(
messageId = messageId,
messageType = messageType,
mine = mine,
createdAt = createdAt,
textMessage = textMessage,
voiceMessageUrl = voiceMessageUrl,
senderId = senderId,
senderNickname = senderNickname,
senderProfileImageUrl = senderProfileImageUrl
)
}