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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package com.gamss.android.domain.conversation

import kotlin.random.Random

const val COMMENT_REVEAL_MIN_GAP_MILLIS = 1_000L
const val COMMENT_REVEAL_MIN_GAP_MILLIS = 500L
const val COMMENT_REVEAL_MAX_GAP_MILLIS = 3_000L

fun nextCommentRevealGapMillis(random: Random = Random.Default): Long =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class ConversationSession @Inject constructor(
private val createConversationCard: CreateConversationCardUseCase,
private val summaryStore: ConversationSummaryStore,
private val emotionAccumulator: ConversationEmotionAccumulator,
private val pendingReveal: PendingConversationReveal,
) {

private val titleMutex = Mutex()
Expand Down Expand Up @@ -75,11 +76,21 @@ class ConversationSession @Inject constructor(
seed = result.data.message.content,
),
)
pendingReveal.save(result.data)
}
}
return result
}

/** 홈 쪽 인스턴스에서만 채워진 요약·감정 상태를, [restore] 와 같은 방식으로 이 인스턴스에도 시드해 둔다. */
suspend fun consumePendingReveal(conversationId: Long): SentMessage? {
val sent = pendingReveal.consume(conversationId) ?: return null
val utterances = listOf(sent.message).userUtterances()
summaryStore.restore(utterances)
emotionAccumulator.restore(utterances)
return sent
}

suspend fun finishSend() = coroutineScope {
launch { assignPendingTitle() }
launch {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.gamss.android.domain.conversation

import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton

const val PENDING_REVEAL_EXPIRY_MILLIS = 30_000L

@Singleton
class PendingConversationReveal @Inject constructor() {

private val mutex = Mutex()

private var pending: Entry? = null

suspend fun save(sent: SentMessage, now: Long = System.currentTimeMillis()) {
mutex.withLock { pending = Entry(sent, now) }
}

suspend fun consume(conversationId: Long, now: Long = System.currentTimeMillis()): SentMessage? =
mutex.withLock {
val current = pending?.takeIf { it.sent.message.conversationId == conversationId } ?: return@withLock null
pending = null
current.sent.takeIf { now - current.savedAtMillis <= PENDING_REVEAL_EXPIRY_MILLIS }
}

private data class Entry(val sent: SentMessage, val savedAtMillis: Long)
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,68 @@ class ConversationSessionTest {
assertEquals(1, repository.updatedTitles.size)
}

@Test
fun 홈이_새_대화를_열면_다른_인스턴스인_채팅방도_reveal_캐시를_받는다() = runBlocking {
val repository = FakeConversationRepository()
val reveal = PendingConversationReveal()
val homeSession = session(repository, reveal)
val chatSession = session(repository, reveal)

val result = homeSession.send(conversationId = null, content = SEED, replyToMessageId = null)
val sent = (result as AppResult.Success).data

assertEquals(sent, chatSession.consumePendingReveal(ROOM_ID))
}

@Test
fun 채팅방이_reveal_캐시를_소비하면_요약_상태도_이어받는다() = runBlocking {
val repository = FakeConversationRepository()
val reveal = PendingConversationReveal()
val homeSession = session(repository, reveal)
val chatSession = session(repository, reveal)

homeSession.send(conversationId = null, content = SEED, replyToMessageId = null)
chatSession.consumePendingReveal(ROOM_ID)
chatSession.send(conversationId = ROOM_ID, content = "팀장이 또 그랬어", replyToMessageId = null)

assertEquals(listOf(null, SEED), repository.sentContextSummaries)
}

@Test
fun reveal_캐시는_한_번_꺼내면_비워진다() = runBlocking {
val repository = FakeConversationRepository()
val reveal = PendingConversationReveal()
val homeSession = session(repository, reveal)
val chatSession = session(repository, reveal)

homeSession.send(conversationId = null, content = SEED, replyToMessageId = null)
chatSession.consumePendingReveal(ROOM_ID)

assertEquals(null, chatSession.consumePendingReveal(ROOM_ID))
}

@Test
fun 다른_대화_ID로는_reveal_캐시를_꺼내지_못한다() = runBlocking {
val repository = FakeConversationRepository()
val reveal = PendingConversationReveal()
val homeSession = session(repository, reveal)
val chatSession = session(repository, reveal)

homeSession.send(conversationId = null, content = SEED, replyToMessageId = null)

assertEquals(null, chatSession.consumePendingReveal(OTHER_ROOM_ID))
}

@Test
fun 이어_쓰는_대화는_reveal_캐시를_남기지_않는다() = runBlocking {
val repository = FakeConversationRepository()
val session = session(repository)

session.continueWith(SEED)

assertEquals(null, session.consumePendingReveal(ROOM_ID))
}

@Test
fun 제목으로_쓸_글자가_없으면_지정을_시도하지_않는다() = runBlocking {
val repository = FakeConversationRepository()
Expand Down Expand Up @@ -182,7 +244,15 @@ class ConversationSessionTest {
finishSend()
}

private fun session(repository: FakeConversationRepository) = ConversationSession(
/**
* 홈과 채팅방은 각자 다른 [ConversationSession] 인스턴스를 Hilt에서 주입받는다(무스코프).
* [pendingReveal]을 생략하면 새 인스턴스가 만들어져 서로 다른 화면처럼 격리되고, 같은
* [PendingConversationReveal]을 넘기면 그 두 인스턴스가 캐시만 공유하는 실제 배선을 재현한다.
*/
private fun session(
repository: FakeConversationRepository,
pendingReveal: PendingConversationReveal = PendingConversationReveal(),
) = ConversationSession(
sendMessage = SendMessageUseCase(repository),
getMessages = GetMessagesUseCase(repository),
updateConversationTitle = UpdateConversationTitleUseCase(repository),
Expand All @@ -196,6 +266,7 @@ class ConversationSessionTest {
tokenCounter = CharLengthTokenCounter,
),
emotionAccumulator = ConversationEmotionAccumulator(FlatClassifier),
pendingReveal = pendingReveal,
)

private object PassThroughSummarizer : DiarySummarizer {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.gamss.android.domain.conversation

import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

class PendingConversationRevealTest {

@Test
fun 저장한_값을_같은_대화_ID로_꺼낼_수_있다() = runBlocking {
val reveal = PendingConversationReveal()
val sent = sentMessage()

reveal.save(sent, now = BASE_TIME)

assertEquals(sent, reveal.consume(ROOM_ID, now = BASE_TIME))
}

@Test
fun 한_번_꺼내면_다시는_꺼낼_수_없다() = runBlocking {
val reveal = PendingConversationReveal()
reveal.save(sentMessage(), now = BASE_TIME)

reveal.consume(ROOM_ID, now = BASE_TIME)

assertNull(reveal.consume(ROOM_ID, now = BASE_TIME))
}

@Test
fun 다른_대화_ID로는_꺼낼_수_없고_원래_값은_남는다() = runBlocking {
val reveal = PendingConversationReveal()
val sent = sentMessage()
reveal.save(sent, now = BASE_TIME)

assertNull(reveal.consume(OTHER_ROOM_ID, now = BASE_TIME))
assertEquals(sent, reveal.consume(ROOM_ID, now = BASE_TIME))
}

@Test
fun 만료_시간_이내면_꺼낼_수_있다() = runBlocking {
val reveal = PendingConversationReveal()
reveal.save(sentMessage(), now = BASE_TIME)

val result = reveal.consume(ROOM_ID, now = BASE_TIME + PENDING_REVEAL_EXPIRY_MILLIS)

assertEquals(ROOM_ID, result?.message?.conversationId)
}

@Test
fun 만료_시간이_지나면_꺼내지_못하고_폐기된다() = runBlocking {
val reveal = PendingConversationReveal()
reveal.save(sentMessage(), now = BASE_TIME)

val expired = reveal.consume(ROOM_ID, now = BASE_TIME + PENDING_REVEAL_EXPIRY_MILLIS + 1)

assertNull(expired)
// 만료돼 버려졌으니 시간을 되돌려도 다시 꺼낼 수 없다 — 자리는 이미 비었다.
assertNull(reveal.consume(ROOM_ID, now = BASE_TIME))
}

private fun sentMessage() = SentMessage(
message = Message(
id = MESSAGE_ID,
conversationId = ROOM_ID,
sender = MessageSender.User,
content = "아 진짜 짜증나",
),
commentStatus = CommentGenerationStatus.DONE,
comments = emptyList(),
)

private companion object {
const val ROOM_ID = 7L
const val OTHER_ROOM_ID = 8L
const val MESSAGE_ID = 100L
const val BASE_TIME = 1_700_000_000_000L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ class ChatRoomViewModel @Inject constructor(

private fun loadMessages(conversationId: Long) = intent {
reduce { state.copy(conversationId = conversationId, isLoading = true) }

val pending = session.consumePendingReveal(conversationId)
if (pending != null) {
reduce {
state.copy(
isLoading = false,
messages = listOf(pending.message),
pendingComments = pending.comments,
)
}
launchCommentReveal()
return@intent
}

when (val result = session.restore(conversationId)) {
is AppResult.Success ->
reduce { state.copy(isLoading = false, messages = result.data, pendingComments = emptyList()) }
Expand Down Expand Up @@ -136,8 +150,8 @@ class ChatRoomViewModel @Inject constructor(
state.copy(
isSending = false,
conversationId = sent.message.conversationId,
messages = state.messages + sent.message + sent.comments.take(1),
pendingComments = sent.comments.drop(1),
messages = state.messages + sent.message,
pendingComments = sent.comments,
input = if (state.input == sending.content) "" else state.input,
replyTarget = state.replyTarget.takeIf { it?.messageId != sending.replyToMessageId },
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.gamss.android.feature.chat

import com.gamss.android.domain.card.CreateCardUseCase
import com.gamss.android.domain.card.CreateConversationCardUseCase
import com.gamss.android.domain.conversation.ConversationSession
import com.gamss.android.domain.conversation.ConversationSummaryStore
import com.gamss.android.domain.conversation.EndConversationUseCase
import com.gamss.android.domain.conversation.GetMessagesUseCase
import com.gamss.android.domain.conversation.PendingConversationReveal
import com.gamss.android.domain.conversation.SendMessageUseCase
import com.gamss.android.domain.conversation.UpdateConversationTitleUseCase
import com.gamss.android.domain.emotion.ConversationEmotionAccumulator
import com.gamss.android.domain.summary.SummarizeDiaryUseCase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.orbitmvi.orbit.test.test

@OptIn(ExperimentalCoroutinesApi::class)
class ChatRoomLoadTest {

@Before
fun setUp() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}

@After
fun tearDown() {
Dispatchers.resetMain()
}

@Test
fun 홈에서_보낸_첫_교환은_채팅방_진입시_순차_노출된다() = runTest {
val repository = FakeConversationRepository(commentCount = 3)
val reveal = PendingConversationReveal()
val homeSession = homeSession(repository, reveal)
homeSession.send(conversationId = null, content = INPUT, replyToMessageId = null)
val viewModel = chatRoomViewModel(conversationRepository = repository, pendingReveal = reveal)

viewModel.test(this) {
containerHost.start(ROOM_ID)
skipItems(1) // useChatEndFeature 반영
skipItems(1) // isLoading = true

val afterLoad = awaitState()
assertEquals(listOf(USER_ID), afterLoad.messages.map { it.id })
assertEquals(
listOf(COMMENT_ID_BASE + 0, COMMENT_ID_BASE + 1, COMMENT_ID_BASE + 2),
afterLoad.pendingComments.map { it.id },
)

val firstReveal = awaitState()
assertEquals(COMMENT_ID_BASE + 0, firstReveal.messages.last().id)

val secondReveal = awaitState()
assertEquals(COMMENT_ID_BASE + 1, secondReveal.messages.last().id)

val thirdReveal = awaitState()
assertEquals(COMMENT_ID_BASE + 2, thirdReveal.messages.last().id)
assertTrue(thirdReveal.pendingComments.isEmpty())

cancelAndIgnoreRemainingItems()
}
}

@Test
fun reveal_캐시가_없으면_서버에서_다시_불러온다() = runTest {
val repository = FakeConversationRepository(commentCount = 3)
val viewModel = chatRoomViewModel(conversationRepository = repository)

viewModel.test(this) {
containerHost.start(ROOM_ID)
skipItems(1) // useChatEndFeature 반영
skipItems(1) // isLoading = true

val afterLoad = awaitState()
assertTrue(afterLoad.messages.isEmpty())
assertTrue(afterLoad.pendingComments.isEmpty())

cancelAndIgnoreRemainingItems()
}
}

/** 홈 화면이 실제로 받는 것과 같은 모양의, 채팅방과는 별개인 ConversationSession 인스턴스. */
private fun homeSession(repository: FakeConversationRepository, pendingReveal: PendingConversationReveal) =
ConversationSession(
sendMessage = SendMessageUseCase(repository),
getMessages = GetMessagesUseCase(repository),
updateConversationTitle = UpdateConversationTitleUseCase(repository),
endConversation = EndConversationUseCase(repository),
createConversationCard = CreateConversationCardUseCase(
summarizeDiary = SummarizeDiaryUseCase(PassThroughSummarizer),
createCard = CreateCardUseCase(CountingCardRepository()),
),
summaryStore = ConversationSummaryStore(
summarizer = PassThroughSummarizer,
tokenCounter = CharLengthTokenCounter,
),
emotionAccumulator = ConversationEmotionAccumulator(FlatClassifier),
pendingReveal = pendingReveal,
)
}
Loading
Loading