feat(dm): 수신자 선택 상태를 추가한다

This commit is contained in:
2026-09-14 22:04:45 +09:00
parent 9de82916dd
commit e9bb2c6dd8
2 changed files with 312 additions and 0 deletions
@@ -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<DmRecipientUiModel> = emptyList(),
val searchResultCount: Int = 0,
val isSearchEmpty: Boolean = false,
val isError: Boolean = false
)
class DmRecipientPickerViewModel(
private val loadFollowers: () -> Single<List<DmRecipientUiModel>>,
private val searchUsers: (String) -> Single<List<DmRecipientUiModel>>,
private val debounceScheduler: Scheduler = Schedulers.computation()
) : BaseViewModel() {
private val _recipientStateLiveData = MutableLiveData(DmRecipientPickerUiState())
val recipientStateLiveData: LiveData<DmRecipientPickerUiState> 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
}
}
@@ -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<String>(), 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<String>(), 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<String>(), 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<List<DmRecipientUiModel>>()
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<List<DmRecipientUiModel>>()
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<DmRecipientUiModel>()
var searchResults = emptyList<DmRecipientUiModel>()
var followerCalls = 0
val searchQueries = mutableListOf<String>()
fun loadFollowers(): Single<List<DmRecipientUiModel>> {
followerCalls += 1
return Single.just(followers)
}
fun searchUsers(query: String): Single<List<DmRecipientUiModel>> {
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 <T> LiveData<T>.requireValue(): T? {
return value
}