From e9bb2c6dd85a501ea68e85401b9d9c5a11b7c0c6 Mon Sep 17 00:00:00 2001 From: klaus Date: Mon, 14 Sep 2026 22:04:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(dm):=20=EC=88=98=EC=8B=A0=EC=9E=90=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../recipient/DmRecipientPickerViewModel.kt | 97 ++++++++ .../DmRecipientPickerViewModelTest.kt | 215 ++++++++++++++++++ 2 files changed, 312 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerViewModel.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerViewModelTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerViewModel.kt new file mode 100644 index 00000000..e8f88f03 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerViewModel.kt @@ -0,0 +1,97 @@ +package kr.co.vividnext.sodalive.v2.main.chat.recipient + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.disposables.SerialDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import kr.co.vividnext.sodalive.base.BaseViewModel +import java.util.concurrent.TimeUnit + +data class DmRecipientUiModel( + val id: Long, + val nickname: String, + val profileImageUrl: String +) + +enum class DmRecipientPickerMode { + FOLLOWERS, + SEARCH +} + +data class DmRecipientPickerUiState( + val mode: DmRecipientPickerMode = DmRecipientPickerMode.FOLLOWERS, + val recipients: List = emptyList(), + val searchResultCount: Int = 0, + val isSearchEmpty: Boolean = false, + val isError: Boolean = false +) + +class DmRecipientPickerViewModel( + private val loadFollowers: () -> Single>, + private val searchUsers: (String) -> Single>, + private val debounceScheduler: Scheduler = Schedulers.computation() +) : BaseViewModel() { + + private val _recipientStateLiveData = MutableLiveData(DmRecipientPickerUiState()) + val recipientStateLiveData: LiveData get() = _recipientStateLiveData + private var searchDisposable: Disposable? = null + private val requestDisposable = SerialDisposable().also(compositeDisposable::add) + + fun enter() { + requestDisposable.set(null) + requestDisposable.set( + loadFollowers() + .subscribe({ recipients -> + _recipientStateLiveData.value = DmRecipientPickerUiState( + mode = DmRecipientPickerMode.FOLLOWERS, + recipients = recipients + ) + }, { + _recipientStateLiveData.value = DmRecipientPickerUiState(isError = true) + }) + ) + } + + fun onSearchQueryChanged(query: String) { + val keyword = query.trim() + searchDisposable?.dispose() + requestDisposable.set(null) + + if (keyword.length < MIN_SEARCH_QUERY_LENGTH) { + enter() + return + } + + val scheduledSearch = debounceScheduler.scheduleDirect( + { + requestDisposable.set( + searchUsers(keyword).subscribe({ recipients -> + _recipientStateLiveData.value = DmRecipientPickerUiState( + mode = DmRecipientPickerMode.SEARCH, + recipients = recipients, + searchResultCount = recipients.size, + isSearchEmpty = recipients.isEmpty() + ) + }, { + _recipientStateLiveData.value = DmRecipientPickerUiState( + mode = DmRecipientPickerMode.SEARCH, + isError = true + ) + }) + ) + }, + SEARCH_DEBOUNCE_MILLIS, + TimeUnit.MILLISECONDS + ) + searchDisposable = scheduledSearch + compositeDisposable.add(scheduledSearch) + } + + private companion object { + const val MIN_SEARCH_QUERY_LENGTH = 2 + const val SEARCH_DEBOUNCE_MILLIS = 500L + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerViewModelTest.kt new file mode 100644 index 00000000..cdc1097c --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerViewModelTest.kt @@ -0,0 +1,215 @@ +package kr.co.vividnext.sodalive.v2.main.chat.recipient + +import android.app.Application +import androidx.lifecycle.LiveData +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.TestScheduler +import io.reactivex.rxjava3.subjects.SingleSubject +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class DmRecipientPickerViewModelTest { + + private lateinit var debounceScheduler: TestScheduler + private lateinit var source: FakeRecipientSource + private lateinit var viewModel: DmRecipientPickerViewModel + + @Before + fun setUp() { + debounceScheduler = TestScheduler() + source = FakeRecipientSource() + viewModel = DmRecipientPickerViewModel( + loadFollowers = source::loadFollowers, + searchUsers = source::searchUsers, + debounceScheduler = debounceScheduler + ) + } + + @Test + fun `진입 시 팔로우 목록을 1회 로드한다`() { + source.followers = listOf(recipient(id = 1L, nickname = "팬A")) + + viewModel.enter() + + assertEquals(1, source.followerCalls) + assertEquals(emptyList(), source.searchQueries) + assertEquals( + DmRecipientPickerUiState( + mode = DmRecipientPickerMode.FOLLOWERS, + recipients = source.followers + ), + viewModel.recipientStateLiveData.requireValue() + ) + } + + @Test + fun `검색어가 0에서 1자이면 검색하지 않고 팔로우 목록으로 복귀한다`() { + source.followers = listOf(recipient(id = 1L, nickname = "팬A")) + viewModel.enter() + + viewModel.onSearchQueryChanged("가") + + assertEquals(2, source.followerCalls) + assertEquals(emptyList(), source.searchQueries) + assertEquals( + DmRecipientPickerUiState( + mode = DmRecipientPickerMode.FOLLOWERS, + recipients = source.followers + ), + viewModel.recipientStateLiveData.requireValue() + ) + } + + @Test + fun `검색어 2자 이상은 500ms 후 마지막 검색어만 요청한다`() { + source.searchResults = listOf(recipient(id = 2L, nickname = "팬B")) + + viewModel.onSearchQueryChanged("가나") + debounceScheduler.advanceTimeBy(499L, TimeUnit.MILLISECONDS) + viewModel.onSearchQueryChanged("가나다") + debounceScheduler.advanceTimeBy(499L, TimeUnit.MILLISECONDS) + + assertEquals(emptyList(), source.searchQueries) + + debounceScheduler.advanceTimeBy(1L, TimeUnit.MILLISECONDS) + + assertEquals(listOf("가나다"), source.searchQueries) + assertEquals( + DmRecipientPickerUiState( + mode = DmRecipientPickerMode.SEARCH, + recipients = source.searchResults, + searchResultCount = source.searchResults.size + ), + viewModel.recipientStateLiveData.requireValue() + ) + } + + @Test + fun `검색 결과가 없으면 empty 상태를 표시한다`() { + source.searchResults = emptyList() + + viewModel.onSearchQueryChanged("없는팬") + debounceScheduler.advanceTimeBy(500L, TimeUnit.MILLISECONDS) + + assertEquals( + DmRecipientPickerUiState( + mode = DmRecipientPickerMode.SEARCH, + recipients = emptyList(), + searchResultCount = 0, + isSearchEmpty = true + ), + viewModel.recipientStateLiveData.requireValue() + ) + } + + @Test + fun `검색 결과 개수는 검색 결과가 0개여도 상태에 포함된다`() { + source.searchResults = emptyList() + + viewModel.onSearchQueryChanged("홍길동") + debounceScheduler.advanceTimeBy(500L, TimeUnit.MILLISECONDS) + + assertEquals(0, viewModel.recipientStateLiveData.requireValue()?.searchResultCount) + } + + @Test + fun `팔로워 요청 실패는 오류 상태로 전달한다`() { + val model = DmRecipientPickerViewModel( + loadFollowers = { Single.error(IllegalStateException()) }, + searchUsers = source::searchUsers, + debounceScheduler = debounceScheduler + ) + + model.enter() + + assertEquals(true, model.recipientStateLiveData.value?.isError) + } + + @Test + fun `검색 요청 실패는 빈 결과 대신 오류 상태로 전달한다`() { + val model = DmRecipientPickerViewModel( + loadFollowers = source::loadFollowers, + searchUsers = { Single.error(IllegalStateException()) }, + debounceScheduler = debounceScheduler + ) + + model.onSearchQueryChanged("검색") + debounceScheduler.advanceTimeBy(500L, TimeUnit.MILLISECONDS) + + assertEquals(true, model.recipientStateLiveData.value?.isError) + assertEquals(false, model.recipientStateLiveData.value?.isSearchEmpty) + } + + @Test + fun `팔로워로 복귀하면 이전 검색 응답은 목록을 덮어쓰지 않는다`() { + val pendingSearch = SingleSubject.create>() + val model = DmRecipientPickerViewModel( + source::loadFollowers, + { pendingSearch }, + debounceScheduler + ) + model.onSearchQueryChanged("검색") + debounceScheduler.advanceTimeBy(500L, TimeUnit.MILLISECONDS) + + model.onSearchQueryChanged("") + pendingSearch.onSuccess(listOf(recipient(2L, "지난검색"))) + + assertEquals(DmRecipientPickerMode.FOLLOWERS, model.recipientStateLiveData.value?.mode) + assertEquals(source.followers, model.recipientStateLiveData.value?.recipients) + } + + @Test + fun `검색으로 전환하면 늦은 팔로워 응답은 검색 결과를 덮어쓰지 않는다`() { + val pendingFollowers = SingleSubject.create>() + val model = DmRecipientPickerViewModel( + { pendingFollowers }, + source::searchUsers, + debounceScheduler + ) + model.enter() + + model.onSearchQueryChanged("검색") + debounceScheduler.advanceTimeBy(500L, TimeUnit.MILLISECONDS) + pendingFollowers.onSuccess(listOf(recipient(1L, "팔로워"))) + + assertEquals(DmRecipientPickerMode.SEARCH, model.recipientStateLiveData.value?.mode) + assertEquals(source.searchResults, model.recipientStateLiveData.value?.recipients) + } + + private class FakeRecipientSource { + var followers = emptyList() + var searchResults = emptyList() + var followerCalls = 0 + val searchQueries = mutableListOf() + + fun loadFollowers(): Single> { + followerCalls += 1 + return Single.just(followers) + } + + fun searchUsers(query: String): Single> { + searchQueries += query + return Single.just(searchResults) + } + } +} + +private fun recipient( + id: Long, + nickname: String +) = DmRecipientUiModel( + id = id, + nickname = nickname, + profileImageUrl = "https://example.com/$id.png" +) + +private fun LiveData.requireValue(): T? { + return value +}