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:
Konrad Pozniak 2022-08-15 11:00:18 +02:00 committed by GitHub
commit 741461acde
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1446 additions and 999 deletions

View file

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

View file

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