feat(dm): 수신자 선택 상태를 추가한다
This commit is contained in:
+97
@@ -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
|
||||
}
|
||||
}
|
||||
+215
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user