rewrite threads with Kotlin & coroutines (#2617)
* initial class setup * handle events and filters * handle status state changes * code formatting * fix status filtering * cleanup code a bit * implement removeAllByAccountId * move toolbar into fragment, implement menu * error and load state handling * fix pull to refresh * implement reveal button * use requireContext() instead of context!! * jump to detailed status * add ViewThreadViewModelTest * fix ktlint * small code improvements (thx charlag) * add testcase for toggleRevealButton * add more state change testcases to ViewThreadViewModel
This commit is contained in:
parent
607f448eb3
commit
741461acde
24 changed files with 1446 additions and 999 deletions
|
@ -10,7 +10,15 @@ import java.util.Date
|
|||
|
||||
private val fixedDate = Date(1638889052000)
|
||||
|
||||
fun mockStatus(id: String = "100") = Status(
|
||||
fun mockStatus(
|
||||
id: String = "100",
|
||||
inReplyToId: String? = null,
|
||||
inReplyToAccountId: String? = null,
|
||||
spoilerText: String = "",
|
||||
reblogged: Boolean = false,
|
||||
favourited: Boolean = true,
|
||||
bookmarked: Boolean = true
|
||||
) = Status(
|
||||
id = id,
|
||||
url = "https://mastodon.example/@ConnyDuck/$id",
|
||||
account = TimelineAccount(
|
||||
|
@ -21,8 +29,8 @@ fun mockStatus(id: String = "100") = Status(
|
|||
url = "https://mastodon.example/@ConnyDuck",
|
||||
avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg"
|
||||
),
|
||||
inReplyToId = null,
|
||||
inReplyToAccountId = null,
|
||||
inReplyToId = inReplyToId,
|
||||
inReplyToAccountId = inReplyToAccountId,
|
||||
reblog = null,
|
||||
content = "Test",
|
||||
createdAt = fixedDate,
|
||||
|
@ -30,11 +38,11 @@ fun mockStatus(id: String = "100") = Status(
|
|||
reblogsCount = 1,
|
||||
favouritesCount = 2,
|
||||
repliesCount = 3,
|
||||
reblogged = false,
|
||||
favourited = true,
|
||||
bookmarked = true,
|
||||
reblogged = reblogged,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
sensitive = true,
|
||||
spoilerText = "",
|
||||
spoilerText = spoilerText,
|
||||
visibility = Status.Visibility.PUBLIC,
|
||||
attachments = ArrayList(),
|
||||
mentions = emptyList(),
|
||||
|
@ -46,11 +54,32 @@ fun mockStatus(id: String = "100") = Status(
|
|||
card = null
|
||||
)
|
||||
|
||||
fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete(
|
||||
status = mockStatus(id),
|
||||
isExpanded = false,
|
||||
isShowingContent = false,
|
||||
isCollapsed = true,
|
||||
fun mockStatusViewData(
|
||||
id: String = "100",
|
||||
inReplyToId: String? = null,
|
||||
inReplyToAccountId: String? = null,
|
||||
isDetailed: Boolean = false,
|
||||
spoilerText: String = "",
|
||||
isExpanded: Boolean = false,
|
||||
isShowingContent: Boolean = false,
|
||||
isCollapsed: Boolean = !isDetailed,
|
||||
reblogged: Boolean = false,
|
||||
favourited: Boolean = true,
|
||||
bookmarked: Boolean = true
|
||||
) = StatusViewData.Concrete(
|
||||
status = mockStatus(
|
||||
id = id,
|
||||
inReplyToId = inReplyToId,
|
||||
inReplyToAccountId = inReplyToAccountId,
|
||||
spoilerText = spoilerText,
|
||||
reblogged = reblogged,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked
|
||||
),
|
||||
isExpanded = isExpanded,
|
||||
isShowingContent = isShowingContent,
|
||||
isCollapsed = isCollapsed,
|
||||
isDetailed = isDetailed
|
||||
)
|
||||
|
||||
fun mockStatusEntityWithAccount(
|
||||
|
|
|
@ -0,0 +1,356 @@
|
|||
package com.keylesspalace.tusky.components.viewthread
|
||||
|
||||
import android.os.Looper.getMainLooper
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.keylesspalace.tusky.appstore.BookmarkEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.FavoriteEvent
|
||||
import com.keylesspalace.tusky.appstore.ReblogEvent
|
||||
import com.keylesspalace.tusky.components.timeline.mockStatus
|
||||
import com.keylesspalace.tusky.components.timeline.mockStatusViewData
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.StatusContext
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.stub
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.IOException
|
||||
|
||||
@Config(sdk = [28])
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ViewThreadViewModelTest {
|
||||
|
||||
private lateinit var api: MastodonApi
|
||||
private lateinit var eventHub: EventHub
|
||||
private lateinit var viewModel: ViewThreadViewModel
|
||||
|
||||
private val threadId = "1234"
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
shadowOf(getMainLooper()).idle()
|
||||
|
||||
api = mock()
|
||||
eventHub = EventHub()
|
||||
val filterModel = FilterModel()
|
||||
val timelineCases = TimelineCases(api, eventHub)
|
||||
val accountManager: AccountManager = mock {
|
||||
on { activeAccount } doReturn AccountEntity(
|
||||
id = 1,
|
||||
domain = "mastodon.test",
|
||||
accessToken = "fakeToken",
|
||||
clientId = "fakeId",
|
||||
clientSecret = "fakeSecret",
|
||||
isActive = true
|
||||
)
|
||||
}
|
||||
viewModel = ViewThreadViewModel(api, filterModel, timelineCases, eventHub, accountManager)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should emit status and context when both load`() {
|
||||
mockSuccessResponses()
|
||||
|
||||
viewModel.loadThread(threadId)
|
||||
|
||||
runBlocking {
|
||||
assertEquals(
|
||||
ThreadUiState.Success(
|
||||
statuses = listOf(
|
||||
mockStatusViewData(id = "1", spoilerText = "Test"),
|
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"),
|
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
|
||||
),
|
||||
revealButton = RevealButtonState.REVEAL,
|
||||
refreshing = false
|
||||
),
|
||||
viewModel.uiState.first()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should emit status even if context fails to load`() {
|
||||
api.stub {
|
||||
onBlocking { statusAsync(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1"))
|
||||
onBlocking { statusContext(threadId) } doReturn NetworkResult.failure(IOException())
|
||||
}
|
||||
|
||||
viewModel.loadThread(threadId)
|
||||
|
||||
runBlocking {
|
||||
assertEquals(
|
||||
ThreadUiState.Success(
|
||||
statuses = listOf(
|
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true)
|
||||
),
|
||||
revealButton = RevealButtonState.NO_BUTTON,
|
||||
refreshing = false
|
||||
),
|
||||
viewModel.uiState.first()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should emit error when status and context fail to load`() {
|
||||
api.stub {
|
||||
onBlocking { statusAsync(threadId) } doReturn NetworkResult.failure(IOException())
|
||||
onBlocking { statusContext(threadId) } doReturn NetworkResult.failure(IOException())
|
||||
}
|
||||
|
||||
viewModel.loadThread(threadId)
|
||||
|
||||
runBlocking {
|
||||
assertEquals(
|
||||
ThreadUiState.Error::class.java,
|
||||
viewModel.uiState.first().javaClass
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should emit error when status fails to load`() {
|
||||
api.stub {
|
||||
onBlocking { statusAsync(threadId) } doReturn NetworkResult.failure(IOException())
|
||||
onBlocking { statusContext(threadId) } doReturn NetworkResult.success(
|
||||
StatusContext(
|
||||
ancestors = listOf(mockStatus(id = "1")),
|
||||
descendants = listOf(mockStatus(id = "3", inReplyToId = "2", inReplyToAccountId = "1"))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.loadThread(threadId)
|
||||
|
||||
runBlocking {
|
||||
assertEquals(
|
||||
ThreadUiState.Error::class.java,
|
||||
viewModel.uiState.first().javaClass
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should update state when reveal button is toggled`() {
|
||||
mockSuccessResponses()
|
||||
|
||||
viewModel.loadThread(threadId)
|
||||
viewModel.toggleRevealButton()
|
||||
|
||||
runBlocking {
|
||||
assertEquals(
|
||||
ThreadUiState.Success(
|
||||
statuses = listOf(
|
||||
mockStatusViewData(id = "1", spoilerText = "Test", isExpanded = true),
|
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isExpanded = true),
|
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test", isExpanded = true)
|
||||
),
|
||||
revealButton = RevealButtonState.HIDE,
|
||||
refreshing = false
|
||||
),
|
||||
viewModel.uiState.first()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle favorite event`() {
|
||||
mockSuccessResponses()
|
||||
|
||||
viewModel.loadThread(threadId)
|
||||
|
||||
eventHub.dispatch(FavoriteEvent(statusId = "1", false))
|
||||
|
||||
runBlocking {
|
||||
assertEquals(
|
||||
ThreadUiState.Success(
|
||||
statuses = listOf(
|
||||
mockStatusViewData(id = "1", spoilerText = "Test", favourited = false),
|
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"),
|
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
|
||||
),
|
||||
revealButton = RevealButtonState.REVEAL,
|
||||
refreshing = false
|
||||
),
|
||||
viewModel.uiState.first()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle reblog event`() {
|
||||
mockSuccessResponses()
|
||||
|
||||
viewModel.loadThread(threadId)
|
||||
|
||||
eventHub.dispatch(ReblogEvent(statusId = "2", true))
|
||||
|
||||
runBlocking {
|
||||
assertEquals(
|
||||
ThreadUiState.Success(
|
||||
statuses = listOf(
|
||||
mockStatusViewData(id = "1", spoilerText = "Test"),
|
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", reblogged = true),
|
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
|
||||
),
|
||||
revealButton = RevealButtonState.REVEAL,
|
||||
refreshing = false
|
||||
),
|
||||
viewModel.uiState.first()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle bookmark event`() {
|
||||
mockSuccessResponses()
|
||||
|
||||
viewModel.loadThread(threadId)
|
||||
|
||||
eventHub.dispatch(BookmarkEvent(statusId = "3", false))
|
||||
|
||||
runBlocking {
|
||||
assertEquals(
|
||||
ThreadUiState.Success(
|
||||
statuses = listOf(
|
||||
mockStatusViewData(id = "1", spoilerText = "Test"),
|
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"),
|
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test", bookmarked = false)
|
||||
),
|
||||
revealButton = RevealButtonState.REVEAL,
|
||||
refreshing = false
|
||||
),
|
||||
viewModel.uiState.first()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should remove status`() {
|
||||
mockSuccessResponses()
|
||||
|
||||
viewModel.loadThread(threadId)
|
||||
|
||||
viewModel.removeStatus(mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test"))
|
||||
|
||||
runBlocking {
|
||||
assertEquals(
|
||||
ThreadUiState.Success(
|
||||
statuses = listOf(
|
||||
mockStatusViewData(id = "1", spoilerText = "Test"),
|
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
|
||||
),
|
||||
revealButton = RevealButtonState.REVEAL,
|
||||
refreshing = false
|
||||
),
|
||||
viewModel.uiState.first()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should change status expanded state`() {
|
||||
mockSuccessResponses()
|
||||
|
||||
viewModel.loadThread(threadId)
|
||||
|
||||
viewModel.changeExpanded(
|
||||
true,
|
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
assertEquals(
|
||||
ThreadUiState.Success(
|
||||
statuses = listOf(
|
||||
mockStatusViewData(id = "1", spoilerText = "Test"),
|
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isExpanded = true),
|
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
|
||||
),
|
||||
revealButton = RevealButtonState.REVEAL,
|
||||
refreshing = false
|
||||
),
|
||||
viewModel.uiState.first()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should change content collapsed state`() {
|
||||
mockSuccessResponses()
|
||||
|
||||
viewModel.loadThread(threadId)
|
||||
|
||||
viewModel.changeContentCollapsed(
|
||||
true,
|
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
assertEquals(
|
||||
ThreadUiState.Success(
|
||||
statuses = listOf(
|
||||
mockStatusViewData(id = "1", spoilerText = "Test"),
|
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isCollapsed = true),
|
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
|
||||
),
|
||||
revealButton = RevealButtonState.REVEAL,
|
||||
refreshing = false
|
||||
),
|
||||
viewModel.uiState.first()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should change content showing state`() {
|
||||
mockSuccessResponses()
|
||||
|
||||
viewModel.loadThread(threadId)
|
||||
|
||||
viewModel.changeContentShowing(
|
||||
true,
|
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
assertEquals(
|
||||
ThreadUiState.Success(
|
||||
statuses = listOf(
|
||||
mockStatusViewData(id = "1", spoilerText = "Test"),
|
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isShowingContent = true),
|
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
|
||||
),
|
||||
revealButton = RevealButtonState.REVEAL,
|
||||
refreshing = false
|
||||
),
|
||||
viewModel.uiState.first()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mockSuccessResponses() {
|
||||
api.stub {
|
||||
onBlocking { statusAsync(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1", spoilerText = "Test"))
|
||||
onBlocking { statusContext(threadId) } doReturn NetworkResult.success(
|
||||
StatusContext(
|
||||
ancestors = listOf(mockStatus(id = "1", spoilerText = "Test")),
|
||||
descendants = listOf(mockStatus(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test"))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue