feat(creator-channel): FanTalk 글 입력 상태 관리를 추가한다

This commit is contained in:
2026-07-08 03:44:22 +09:00
parent e8e8a63d6d
commit 34266abd90
3 changed files with 256 additions and 0 deletions

View File

@@ -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
)
} }

View File

@@ -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
}
}

View File

@@ -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
}
}