feat(creator-channel): FanTalk 글 입력 상태 관리를 추가한다
This commit is contained in:
@@ -160,4 +160,11 @@ class CreatorChannelRepository(
|
|||||||
request = PutModifyCheersRequest(cheersId = fanTalkId, isActive = false),
|
request = PutModifyCheersRequest(cheersId = fanTalkId, isActive = false),
|
||||||
token = token
|
token = token
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun writeFanTalk(creatorId: Long, content: String, token: String) = explorerRepository.writeCheers(
|
||||||
|
parentCheersId = null,
|
||||||
|
creatorId = creatorId,
|
||||||
|
content = content,
|
||||||
|
token = token
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.write
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import com.orhanobut.logger.Logger
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kr.co.vividnext.sodalive.base.BaseViewModel
|
||||||
|
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
|
||||||
|
|
||||||
|
class CreatorChannelFanTalkWriteViewModel(
|
||||||
|
private val repository: CreatorChannelRepository
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
private val _contentLiveData = MutableLiveData("")
|
||||||
|
val contentLiveData: LiveData<String>
|
||||||
|
get() = _contentLiveData
|
||||||
|
|
||||||
|
private val _isSendEnabledLiveData = MutableLiveData(false)
|
||||||
|
val isSendEnabledLiveData: LiveData<Boolean>
|
||||||
|
get() = _isSendEnabledLiveData
|
||||||
|
|
||||||
|
private val _contentLengthLiveData = MutableLiveData(0)
|
||||||
|
val contentLengthLiveData: LiveData<Int>
|
||||||
|
get() = _contentLengthLiveData
|
||||||
|
|
||||||
|
private val _isLoadingLiveData = MutableLiveData(false)
|
||||||
|
val isLoadingLiveData: LiveData<Boolean>
|
||||||
|
get() = _isLoadingLiveData
|
||||||
|
|
||||||
|
private val _finishEventLiveData = MutableLiveData(false)
|
||||||
|
val finishEventLiveData: LiveData<Boolean>
|
||||||
|
get() = _finishEventLiveData
|
||||||
|
|
||||||
|
private val _toastEventLiveData = MutableLiveData<String?>()
|
||||||
|
val toastEventLiveData: LiveData<String?>
|
||||||
|
get() = _toastEventLiveData
|
||||||
|
|
||||||
|
fun onContentChanged(content: String) {
|
||||||
|
val limitedContent = content.take(MAX_CONTENT_LENGTH)
|
||||||
|
_contentLiveData.value = limitedContent
|
||||||
|
_contentLengthLiveData.value = limitedContent.length
|
||||||
|
_isSendEnabledLiveData.value = limitedContent.trim().isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submit(creatorId: Long) {
|
||||||
|
val trimmedContent = _contentLiveData.value.orEmpty().trim()
|
||||||
|
if (creatorId <= 0L) return
|
||||||
|
if (_isLoadingLiveData.value == true) return
|
||||||
|
if (trimmedContent.isEmpty()) return
|
||||||
|
|
||||||
|
_isLoadingLiveData.value = true
|
||||||
|
compositeDisposable.add(
|
||||||
|
repository.writeFanTalk(
|
||||||
|
creatorId = creatorId,
|
||||||
|
content = trimmedContent,
|
||||||
|
token = "Bearer ${SharedPreferenceManager.token}"
|
||||||
|
)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{ response ->
|
||||||
|
_isLoadingLiveData.value = false
|
||||||
|
if (response.success) {
|
||||||
|
_finishEventLiveData.value = true
|
||||||
|
} else {
|
||||||
|
_toastEventLiveData.value = response.message ?: unknownErrorMessage()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ error ->
|
||||||
|
_isLoadingLiveData.value = false
|
||||||
|
error.message?.let { Logger.e(it) }
|
||||||
|
_toastEventLiveData.value = unknownErrorMessage()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unknownErrorMessage(): String {
|
||||||
|
return SodaLiveApplicationHolder.get().getString(R.string.common_error_unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MAX_CONTENT_LENGTH: Int = 500
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.write
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins
|
||||||
|
import io.reactivex.rxjava3.core.Scheduler
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.mockito.kotlin.any
|
||||||
|
import org.mockito.kotlin.never
|
||||||
|
import org.mockito.kotlin.times
|
||||||
|
import org.mockito.kotlin.verify
|
||||||
|
import org.mockito.kotlin.whenever
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [28], application = Application::class)
|
||||||
|
class CreatorChannelFanTalkWriteViewModelTest {
|
||||||
|
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
private lateinit var repository: CreatorChannelRepository
|
||||||
|
private lateinit var viewModel: CreatorChannelFanTalkWriteViewModel
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
setImmediateRxSchedulers()
|
||||||
|
SharedPreferenceManager.resetForTest()
|
||||||
|
SharedPreferenceManager.init(context)
|
||||||
|
SharedPreferenceManager.token = "test-token"
|
||||||
|
repository = org.mockito.kotlin.mock()
|
||||||
|
viewModel = CreatorChannelFanTalkWriteViewModel(repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
RxJavaPlugins.reset()
|
||||||
|
RxAndroidPlugins.reset()
|
||||||
|
SharedPreferenceManager.resetForTest()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `초기 입력값은 빈 값이고 보내기 버튼은 비활성화된다`() {
|
||||||
|
assertEquals("", viewModel.contentLiveData.requireValue())
|
||||||
|
assertEquals(false, viewModel.isSendEnabledLiveData.requireValue())
|
||||||
|
assertEquals(0, viewModel.contentLengthLiveData.requireValue())
|
||||||
|
assertEquals(false, viewModel.isLoadingLiveData.requireValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `공백과 줄바꿈만 입력하면 보내기 버튼은 비활성화된다`() {
|
||||||
|
viewModel.onContentChanged(" \n \t")
|
||||||
|
|
||||||
|
assertEquals(" \n \t", viewModel.contentLiveData.requireValue())
|
||||||
|
assertEquals(5, viewModel.contentLengthLiveData.requireValue())
|
||||||
|
assertEquals(false, viewModel.isSendEnabledLiveData.requireValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `trim 결과가 비어 있지 않으면 보내기 버튼은 활성화된다`() {
|
||||||
|
viewModel.onContentChanged(" 응원합니다 ")
|
||||||
|
|
||||||
|
assertEquals(true, viewModel.isSendEnabledLiveData.requireValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `500자를 초과하면 입력값과 글자수는 500자로 제한된다`() {
|
||||||
|
val content = "가".repeat(501)
|
||||||
|
|
||||||
|
viewModel.onContentChanged(content)
|
||||||
|
|
||||||
|
assertEquals("가".repeat(500), viewModel.contentLiveData.requireValue())
|
||||||
|
assertEquals(500, viewModel.contentLengthLiveData.requireValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `비활성 상태에서 submit하면 repository를 호출하지 않는다`() {
|
||||||
|
viewModel.submit(creatorId = 100L)
|
||||||
|
|
||||||
|
verify(repository, never()).writeFanTalk(any(), any(), any())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `creatorId가 유효하지 않으면 repository를 호출하지 않는다`() {
|
||||||
|
viewModel.onContentChanged("응원합니다")
|
||||||
|
|
||||||
|
viewModel.submit(creatorId = 0L)
|
||||||
|
|
||||||
|
verify(repository, never()).writeFanTalk(any(), any(), any())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `활성 상태에서 submit하면 trim content와 토큰으로 FanTalk 등록 API를 호출한다`() {
|
||||||
|
whenever(repository.writeFanTalk(100L, "응원합니다", "Bearer test-token"))
|
||||||
|
.thenReturn(Single.just(ApiResponse<Any>(true, null, null)))
|
||||||
|
viewModel.onContentChanged(" 응원합니다 ")
|
||||||
|
|
||||||
|
viewModel.submit(creatorId = 100L)
|
||||||
|
|
||||||
|
verify(repository).writeFanTalk(100L, "응원합니다", "Bearer test-token")
|
||||||
|
assertTrue(viewModel.finishEventLiveData.requireValue() == true)
|
||||||
|
assertEquals(false, viewModel.isLoadingLiveData.requireValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `등록 중에는 중복 submit을 무시한다`() {
|
||||||
|
whenever(repository.writeFanTalk(100L, "응원합니다", "Bearer test-token"))
|
||||||
|
.thenReturn(Single.never())
|
||||||
|
viewModel.onContentChanged("응원합니다")
|
||||||
|
|
||||||
|
viewModel.submit(creatorId = 100L)
|
||||||
|
viewModel.submit(creatorId = 100L)
|
||||||
|
|
||||||
|
verify(repository, times(1)).writeFanTalk(100L, "응원합니다", "Bearer test-token")
|
||||||
|
assertEquals(true, viewModel.isLoadingLiveData.requireValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `등록 실패 시 입력값을 유지하고 서버 message를 우선 toast로 emit한다`() {
|
||||||
|
whenever(repository.writeFanTalk(100L, "응원합니다", "Bearer test-token"))
|
||||||
|
.thenReturn(Single.just(ApiResponse<Any>(false, null, "서버 실패")))
|
||||||
|
viewModel.onContentChanged(" 응원합니다 ")
|
||||||
|
|
||||||
|
viewModel.submit(creatorId = 100L)
|
||||||
|
|
||||||
|
assertEquals(" 응원합니다 ", viewModel.contentLiveData.requireValue())
|
||||||
|
assertEquals("서버 실패", viewModel.toastEventLiveData.requireValue())
|
||||||
|
assertEquals(false, viewModel.isLoadingLiveData.requireValue())
|
||||||
|
assertFalse(viewModel.finishEventLiveData.requireValue() == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setImmediateRxSchedulers() {
|
||||||
|
val trampoline = { _: Scheduler -> Schedulers.trampoline() }
|
||||||
|
RxJavaPlugins.setIoSchedulerHandler(trampoline)
|
||||||
|
RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
|
||||||
|
RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> LiveData<T>.requireValue(): T? {
|
||||||
|
var value: T? = null
|
||||||
|
val observer = Observer<T> { value = it }
|
||||||
|
observeForever(observer)
|
||||||
|
removeObserver(observer)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user