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