Timeline refactor (#2175)

* Move Timeline files into their own package

* Introduce TimelineViewModel, add coroutines

* Simplify StatusViewData

* Handle timeilne fetch errors

* Rework filters, fix ViewThreadFragment

* Fix NotificationsFragment

* Simplify Notifications and Thread, handle pin

* Redo loading in TimelineViewModel

* Improve error handling in TimelineViewModel

* Rewrite actions in TimelineViewModel

* Apply feedback after timeline factoring review

* Handle initial failure in timeline correctly
This commit is contained in:
Ivan Kupalov 2021-06-11 20:15:40 +02:00 committed by GitHub
commit 44a5b42cac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 3956 additions and 3618 deletions

View file

@ -0,0 +1,355 @@
package com.keylesspalace.tusky.components.timeline
import android.text.SpannableString
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.gson.Gson
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.TimelineDao
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Either
import com.nhaarman.mockitokotlin2.isNull
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
import com.nhaarman.mockitokotlin2.whenever
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.schedulers.TestScheduler
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.*
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import org.robolectric.annotation.Config
import retrofit2.Response
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.collections.ArrayList
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
class TimelineRepositoryTest {
@Mock
lateinit var timelineDao: TimelineDao
@Mock
lateinit var mastodonApi: MastodonApi
@Mock
private lateinit var accountManager: AccountManager
private lateinit var gson: Gson
private lateinit var subject: TimelineRepository
private lateinit var testScheduler: TestScheduler
private val limit = 30
private val account = AccountEntity(
id = 2,
accessToken = "token",
domain = "domain.com",
isActive = true
)
@Before
fun setup() {
MockitoAnnotations.initMocks(this)
whenever(accountManager.activeAccount).thenReturn(account)
gson = Gson()
testScheduler = TestScheduler()
RxJavaPlugins.setIoSchedulerHandler { testScheduler }
subject = TimelineRepositoryImpl(timelineDao, mastodonApi, accountManager, gson)
}
@Test
fun testNetworkUnbounded() {
val statuses = listOf(
makeStatus("3"),
makeStatus("2")
)
whenever(mastodonApi.homeTimeline(isNull(), isNull(), anyInt()))
.thenReturn(Single.just(Response.success(statuses)))
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.NETWORK)
.blockingGet()
assertEquals(statuses.map(Status::lift), result)
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
verify(timelineDao).deleteRange(account.id, statuses.last().id, statuses.first().id)
verify(timelineDao).insertStatusIfNotThere(Placeholder("1").toEntity(account.id))
for (status in statuses) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
}
verify(timelineDao).cleanup(anyLong())
verifyNoMoreInteractions(timelineDao)
}
@Test
fun testNetworkLoadingTopNoGap() {
val response = listOf(
makeStatus("4"),
makeStatus("3"),
makeStatus("2")
)
val sinceId = "2"
val sinceIdMinusOne = "1"
whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1))
.thenReturn(Single.just(Response.success(response)))
val result = subject.getStatuses(
null, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK
)
.blockingGet()
assertEquals(
response.subList(0, 2).map(Status::lift),
result
)
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
// We assume for now that overlapped one is inserted but it's not that important
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
}
verify(timelineDao).removeAllPlaceholdersBetween(
account.id, response.first().id,
response.last().id
)
verify(timelineDao).cleanup(anyLong())
verifyNoMoreInteractions(timelineDao)
}
@Test
fun testNetworkLoadingTopWithGap() {
val response = listOf(
makeStatus("5"),
makeStatus("4")
)
val sinceId = "2"
val sinceIdMinusOne = "1"
whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1))
.thenReturn(Single.just(Response.success(response)))
val result = subject.getStatuses(
null, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK
)
.blockingGet()
val placeholder = Placeholder("3")
assertEquals(response.map(Status::lift) + Either.Left(placeholder), result)
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
}
verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id))
verify(timelineDao).cleanup(anyLong())
verifyNoMoreInteractions(timelineDao)
}
@Test
fun testNetworkLoadingMiddleNoGap() {
// Example timelne:
// 5
// 4
// [gap]
// 2
// 1
val response = listOf(
makeStatus("5"),
makeStatus("4"),
makeStatus("3"),
makeStatus("2")
)
val sinceId = "2"
val sinceIdMinusOne = "1"
val maxId = "3"
whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1))
.thenReturn(Single.just(Response.success(response)))
val result = subject.getStatuses(
maxId, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK
)
.blockingGet()
assertEquals(
response.subList(0, response.lastIndex).map(Status::lift),
result
)
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
// We assume for now that overlapped one is inserted but it's not that important
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
}
verify(timelineDao).removeAllPlaceholdersBetween(
account.id, response.first().id,
response.last().id
)
verify(timelineDao).cleanup(anyLong())
verifyNoMoreInteractions(timelineDao)
}
@Test
fun testNetworkLoadingMiddleWithGap() {
// Example timelne:
// 6
// 5
// [gap]
// 2
// 1
val response = listOf(
makeStatus("6"),
makeStatus("5"),
makeStatus("4")
)
val sinceId = "2"
val sinceIdMinusOne = "1"
val maxId = "4"
whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1))
.thenReturn(Single.just(Response.success(response)))
val result = subject.getStatuses(
maxId, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK
)
.blockingGet()
val placeholder = Placeholder("3")
assertEquals(
response.map(Status::lift) + Either.Left(placeholder),
result
)
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
// We assume for now that overlapped one is inserted but it's not that important
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
}
verify(timelineDao).removeAllPlaceholdersBetween(
account.id, response.first().id,
response.last().id
)
verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id))
verify(timelineDao).cleanup(anyLong())
verifyNoMoreInteractions(timelineDao)
}
@Test
fun addingFromDb() {
RxJavaPlugins.setIoSchedulerHandler { Schedulers.single() }
val status = makeStatus("2")
val dbStatus = makeStatus("1")
val dbResult = TimelineStatusWithAccount()
dbResult.status = dbStatus.toEntity(account.id, gson)
dbResult.account = status.account.toEntity(account.id, gson)
whenever(mastodonApi.homeTimeline(any(), any(), any()))
.thenReturn(Single.just(Response.success((listOf(status)))))
whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30))
.thenReturn(Single.just(listOf(dbResult)))
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY)
.blockingGet()
assertEquals(listOf(status, dbStatus).map(Status::lift), result)
}
@Test
fun addingFromDbExhausted() {
RxJavaPlugins.setIoSchedulerHandler { Schedulers.single() }
val status = makeStatus("4")
val dbResult = TimelineStatusWithAccount()
dbResult.status = Placeholder("2").toEntity(account.id)
val dbResult2 = TimelineStatusWithAccount()
dbResult2.status = Placeholder("1").toEntity(account.id)
whenever(mastodonApi.homeTimeline(any(), any(), any()))
.thenReturn(Single.just(Response.success(listOf(status))))
whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30))
.thenReturn(Single.just(listOf(dbResult, dbResult2)))
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY)
.blockingGet()
assertEquals(listOf(status).map(Status::lift), result)
}
}
fun makeAccount(id: String): Account {
return Account(
id = id,
localUsername = "test$id",
username = "test$id@example.com",
displayName = "Example Account $id",
note = SpannableString("Note! $id"),
url = "https://example.com/@test$id",
avatar = "avatar$id",
header = "Header$id",
followersCount = 300,
followingCount = 400,
statusesCount = 1000,
bot = false,
emojis = listOf(),
fields = null,
source = null
)
}
fun makeStatus(id: String, account: Account = makeAccount(id)): Status {
return Status(
id = id,
account = account,
content = SpannableString("hello$id"),
createdAt = Date(),
emojis = listOf(),
reblogsCount = 3,
favouritesCount = 5,
sensitive = false,
visibility = Status.Visibility.PUBLIC,
spoilerText = "",
reblogged = true,
favourited = false,
bookmarked = false,
attachments = ArrayList(),
mentions = listOf(),
application = null,
inReplyToAccountId = null,
inReplyToId = null,
pinned = false,
muted = false,
reblog = null,
url = "http://example.com/statuses/$id",
poll = null,
card = null
)
}

View file

@ -0,0 +1,783 @@
package com.keylesspalace.tusky.components.timeline
import android.content.SharedPreferences
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.timeline.TimelineViewModel.Companion.LOAD_AT_ONCE
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.PollOption
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.nhaarman.mockitokotlin2.*
import io.reactivex.rxjava3.annotations.NonNull
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.observers.TestObserver
import io.reactivex.rxjava3.subjects.PublishSubject
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowLog
import retrofit2.Response
import java.io.IOException
@Config(sdk = [29])
class TimelineViewModelTest {
lateinit var timelineRepository: TimelineRepository
lateinit var timelineCases: TimelineCases
lateinit var mastodonApi: MastodonApi
lateinit var eventHub: EventHub
lateinit var viewModel: TimelineViewModel
lateinit var accountManager: AccountManager
lateinit var sharedPreference: SharedPreferences
@Before
fun setup() {
ShadowLog.stream = System.out
timelineRepository = mock()
timelineCases = mock()
mastodonApi = mock()
eventHub = mock {
on { events } doReturn Observable.never()
}
val account = AccountEntity(
0,
"domain",
"accessToken",
isActive = true,
)
accountManager = mock {
on { activeAccount } doReturn account
}
sharedPreference = mock()
viewModel = TimelineViewModel(
timelineRepository,
timelineCases,
mastodonApi,
eventHub,
accountManager,
sharedPreference,
FilterModel()
)
}
@Test
fun `loadInitial, home, without cache, empty response`() {
val initialResponse = listOf<Status>()
setCachedResponse(initialResponse)
// loadAbove -> loadBelow
whenever(
timelineRepository.getStatuses(
maxId = null,
sinceId = null,
sincedIdMinusOne = null,
requestMode = TimelineRequestMode.ANY,
limit = LOAD_AT_ONCE
)
).thenReturn(Single.just(listOf()))
runBlocking {
viewModel.loadInitial()
}
verify(timelineRepository).getStatuses(
null,
null,
null,
LOAD_AT_ONCE,
TimelineRequestMode.ANY
)
}
@Test
fun `loadInitial, home, without cache, single item in response`() {
setCachedResponse(listOf())
val status = makeStatus("1")
whenever(
timelineRepository.getStatuses(
isNull(),
isNull(),
isNull(),
eq(LOAD_AT_ONCE),
eq(TimelineRequestMode.ANY)
)
).thenReturn(
Single.just(
listOf(
Either.Right(status)
)
)
)
val updates = viewModel.viewUpdates.test()
runBlocking {
viewModel.loadInitial()
}
verify(timelineRepository).getStatuses(
isNull(),
isNull(),
isNull(),
eq(LOAD_AT_ONCE),
eq(TimelineRequestMode.ANY)
)
assertViewUpdated(updates)
assertHasList(listOf(status).toViewData())
}
@Test
fun `loadInitial, list`() {
val listId = "listId"
viewModel.init(TimelineViewModel.Kind.LIST, listId, listOf())
val status = makeStatus("1")
whenever(
mastodonApi.listTimeline(
listId,
null,
null,
LOAD_AT_ONCE,
)
).thenReturn(
Single.just(
Response.success(
listOf(
status
)
)
)
)
val updates = viewModel.viewUpdates.test()
runBlocking {
viewModel.loadInitial().join()
}
assertViewUpdated(updates)
assertHasList(listOf(status).toViewData())
assertFalse("loading", viewModel.isLoadingInitially)
}
@Test
fun `loadInitial, home, without cache, error on load`() {
setCachedResponse(listOf())
whenever(
timelineRepository.getStatuses(
maxId = null,
sinceId = null,
sincedIdMinusOne = null,
limit = LOAD_AT_ONCE,
TimelineRequestMode.ANY,
)
).thenReturn(Single.error(IOException("test")))
val updates = viewModel.viewUpdates.test()
runBlocking {
viewModel.loadInitial()
}
verify(timelineRepository).getStatuses(
isNull(),
isNull(),
isNull(),
eq(LOAD_AT_ONCE),
eq(TimelineRequestMode.ANY)
)
assertViewUpdated(updates)
assertHasList(listOf())
assertEquals(TimelineViewModel.FailureReason.NETWORK, viewModel.failure)
}
@Test
fun `loadInitial, home, with cache, error on load above`() {
val statuses = (5 downTo 1).map { makeStatus(it.toString()) }
setCachedResponse(statuses)
setInitialRefresh("6", statuses)
whenever(
timelineRepository.getStatuses(
maxId = null,
sinceId = "5",
sincedIdMinusOne = "4",
limit = LOAD_AT_ONCE,
TimelineRequestMode.NETWORK,
)
).thenReturn(Single.error(IOException("test")))
val updates = viewModel.viewUpdates.test()
runBlocking {
viewModel.loadInitial()
}
assertViewUpdated(updates)
assertHasList(statuses.toViewData())
// No failure set since we had statuses
assertNull(viewModel.failure)
}
@Test
fun `loadInitial, home, with cache, error on refresh`() {
val statuses = (5 downTo 2).map { makeStatus(it.toString()) }
setCachedResponse(statuses)
// Error on refreshing cached
whenever(
timelineRepository.getStatuses(
maxId = "6",
sinceId = null,
sincedIdMinusOne = null,
limit = LOAD_AT_ONCE,
TimelineRequestMode.NETWORK,
)
).thenReturn(Single.error(IOException("test")))
// Empty on loading above
setLoadAbove("5", "4", listOf())
val updates = viewModel.viewUpdates.test()
runBlocking {
viewModel.loadInitial()
}
assertViewUpdated(updates)
assertHasList(statuses.toViewData())
assertNull(viewModel.failure)
}
@Test
fun `loads above cached`() {
val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) }
setCachedResponse(cachedStatuses)
setInitialRefresh("6", cachedStatuses)
val additionalStatuses = (10 downTo 6)
.map { makeStatus(it.toString()) }
whenever(
timelineRepository.getStatuses(
null,
"5",
"4",
LOAD_AT_ONCE,
TimelineRequestMode.NETWORK
)
).thenReturn(Single.just(additionalStatuses.toEitherList()))
runBlocking {
viewModel.loadInitial()
}
// We could also check refresh progress here but it's a bit cumbersome
assertHasList(additionalStatuses.plus(cachedStatuses).toViewData())
}
@Test
fun refresh() {
val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) }
setCachedResponse(cachedStatuses)
setInitialRefresh("6", cachedStatuses)
val additionalStatuses = listOf(makeStatus("6"))
whenever(
timelineRepository.getStatuses(
null,
"5",
"4",
LOAD_AT_ONCE,
TimelineRequestMode.NETWORK
)
).thenReturn(Single.just(additionalStatuses.toEitherList()))
runBlocking {
viewModel.loadInitial()
}
clearInvocations(timelineRepository)
val newStatuses = (8 downTo 7).map { makeStatus(it.toString()) }
// Loading above the cached manually
whenever(
timelineRepository.getStatuses(
null,
"6",
"5",
LOAD_AT_ONCE,
TimelineRequestMode.NETWORK
)
).thenReturn(Single.just(newStatuses.toEitherList()))
runBlocking {
viewModel.refresh()
}
val allStatuses = newStatuses + additionalStatuses + cachedStatuses
assertHasList(allStatuses.toViewData())
}
@Test
fun `refresh failed`() {
val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) }
setCachedResponse(cachedStatuses)
setInitialRefresh("6", cachedStatuses)
setLoadAbove("5", "4", listOf())
runBlocking {
viewModel.loadInitial()
}
clearInvocations(timelineRepository)
// Loading above the cached manually
whenever(
timelineRepository.getStatuses(
null,
"6",
"5",
LOAD_AT_ONCE,
TimelineRequestMode.NETWORK
)
).thenReturn(Single.error(IOException("test")))
runBlocking {
viewModel.refresh().join()
}
assertHasList(cachedStatuses.map { it.toViewData(false, false) })
assertFalse("refreshing", viewModel.isRefreshing)
assertNull("failure is not set", viewModel.failure)
}
@Test
fun loadMore() {
val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) }
setCachedResponse(cachedStatuses)
setInitialRefresh("11", cachedStatuses)
// Nothing above
setLoadAbove("10", "9", listOf())
runBlocking {
viewModel.loadInitial().join()
}
clearInvocations(timelineRepository)
val oldStatuses = (4 downTo 1).map { makeStatus(it.toString()) }
// Loading below the cached
whenever(
timelineRepository.getStatuses(
"5",
null,
null,
LOAD_AT_ONCE,
TimelineRequestMode.ANY
)
).thenReturn(Single.just(oldStatuses.toEitherList()))
runBlocking {
viewModel.loadMore().join()
}
val allStatuses = cachedStatuses + oldStatuses
assertHasList(allStatuses.toViewData())
}
@Test
fun `loadMore parallel`() {
val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) }
setCachedResponse(cachedStatuses)
setInitialRefresh("11", cachedStatuses)
// Nothing above
setLoadAbove("10", "9", listOf())
runBlocking {
viewModel.loadInitial().join()
}
clearInvocations(timelineRepository)
val oldStatuses = (4 downTo 1).map { makeStatus(it.toString()) }
val responseSubject = PublishSubject.create<List<TimelineStatus>>()
// Loading below the cached
whenever(
timelineRepository.getStatuses(
"5",
null,
null,
LOAD_AT_ONCE,
TimelineRequestMode.ANY
)
).thenReturn(responseSubject.firstOrError())
clearInvocations(timelineRepository)
runBlocking {
// Trigger them in parallel
val job1 = viewModel.loadMore()
val job2 = viewModel.loadMore()
// Send the response
responseSubject.onNext(oldStatuses.toEitherList())
// Wait for both
job1.join()
job2.join()
}
val allStatuses = cachedStatuses + oldStatuses
assertHasList(allStatuses.toViewData())
verify(timelineRepository, times(1)).getStatuses(
"5",
null,
null,
LOAD_AT_ONCE,
TimelineRequestMode.ANY
)
}
@Test
fun `loadMore failed`() {
val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) }
setCachedResponse(cachedStatuses)
setInitialRefresh("11", cachedStatuses)
// Nothing above
setLoadAbove("10", "9", listOf())
runBlocking {
viewModel.loadInitial().join()
}
clearInvocations(timelineRepository)
// Loading below the cached
whenever(
timelineRepository.getStatuses(
"5",
null,
null,
LOAD_AT_ONCE,
TimelineRequestMode.ANY
)
).thenReturn(Single.error(IOException("test")))
runBlocking {
viewModel.loadMore().join()
}
assertHasList(cachedStatuses.toViewData())
// Check that we can still load after that
val oldStatuses = listOf(makeStatus("4"))
whenever(
timelineRepository.getStatuses(
"5",
null,
null,
LOAD_AT_ONCE,
TimelineRequestMode.ANY
)
).thenReturn(Single.just(oldStatuses.toEitherList()))
runBlocking {
viewModel.loadMore().join()
}
assertHasList((cachedStatuses + oldStatuses).toViewData())
}
@Test
fun loadGap() {
val status5 = makeStatus("5")
val status4 = makeStatus("4")
val status3 = makeStatus("3")
val status1 = makeStatus("1")
val cachedStatuses: List<TimelineStatus> = listOf(
Either.Right(status5),
Either.Left(Placeholder("4")),
Either.Right(status1)
)
val laterFetchedStatuses = listOf<TimelineStatus>(
Either.Right(status4),
Either.Right(status3),
)
setCachedResponseWithGaps(cachedStatuses)
setInitialRefreshWithGaps("6", cachedStatuses)
// Nothing above
setLoadAbove("5", items = listOf())
whenever(
timelineRepository.getStatuses(
"5",
"1",
null,
LOAD_AT_ONCE,
TimelineRequestMode.NETWORK
)
).thenReturn(Single.just(laterFetchedStatuses))
runBlocking {
viewModel.loadInitial().join()
viewModel.loadGap(1).join()
}
assertHasList(
listOf(
status5,
status4,
status3,
status1
).toViewData()
)
}
@Test
fun `loadGap failed`() {
val status5 = makeStatus("5")
val status1 = makeStatus("1")
val cachedStatuses: List<TimelineStatus> = listOf(
Either.Right(status5),
Either.Left(Placeholder("4")),
Either.Right(status1)
)
setCachedResponseWithGaps(cachedStatuses)
setInitialRefreshWithGaps("6", cachedStatuses)
setLoadAbove("5", items = listOf())
whenever(
timelineRepository.getStatuses(
"5",
"1",
null,
LOAD_AT_ONCE,
TimelineRequestMode.NETWORK
)
).thenReturn(Single.error(IOException("test")))
runBlocking {
viewModel.loadInitial().join()
viewModel.loadGap(1).join()
}
assertHasList(
listOf(
status5.toViewData(false, false),
StatusViewData.Placeholder("4", false),
status1.toViewData(false, false),
)
)
}
@Test
fun favorite() {
val status5 = makeStatus("5")
val status4 = makeStatus("4")
val status3 = makeStatus("3")
val statuses = listOf(status5, status4, status3)
setCachedResponse(statuses)
setInitialRefresh("6", statuses)
setLoadAbove("5", "4", listOf())
runBlocking { viewModel.loadInitial() }
whenever(timelineCases.favourite("4", true))
.thenReturn(Single.just(status4.copy(favourited = true)))
runBlocking {
viewModel.favorite(true, 1).join()
}
verify(timelineCases).favourite("4", true)
assertHasList(listOf(status5, status4.copy(favourited = true), status3).toViewData())
}
@Test
fun reblog() {
val status5 = makeStatus("5")
val status4 = makeStatus("4")
val status3 = makeStatus("3")
val statuses = listOf(status5, status4, status3)
setCachedResponse(statuses)
setInitialRefresh("6", statuses)
setLoadAbove("5", "4", listOf())
runBlocking { viewModel.loadInitial() }
whenever(timelineCases.reblog("4", true))
.thenReturn(Single.just(status4.copy(reblogged = true)))
runBlocking {
viewModel.reblog(true, 1).join()
}
verify(timelineCases).reblog("4", true)
assertHasList(listOf(status5, status4.copy(reblogged = true), status3).toViewData())
}
@Test
fun bookmark() {
val status5 = makeStatus("5")
val status4 = makeStatus("4")
val status3 = makeStatus("3")
val statuses = listOf(status5, status4, status3)
setCachedResponse(statuses)
setInitialRefresh("6", statuses)
setLoadAbove("5", "4", listOf())
runBlocking { viewModel.loadInitial() }
whenever(timelineCases.bookmark("4", true))
.thenReturn(Single.just(status4.copy(bookmarked = true)))
runBlocking {
viewModel.bookmark(true, 1).join()
}
verify(timelineCases).bookmark("4", true)
assertHasList(listOf(status5, status4.copy(bookmarked = true), status3).toViewData())
}
@Test
fun voteInPoll() {
val status5 = makeStatus("5")
val poll = Poll(
"1",
expiresAt = null,
expired = false,
multiple = false,
votersCount = 1,
votesCount = 1,
voted = false,
options = listOf(PollOption("1", 1), PollOption("2", 2)),
)
val status4 = makeStatus("4").copy(poll = poll)
val status3 = makeStatus("3")
val statuses = listOf(status5, status4, status3)
setCachedResponse(statuses)
setInitialRefresh("6", statuses)
setLoadAbove("5", "4", listOf())
runBlocking { viewModel.loadInitial() }
val votedPoll = poll.votedCopy(listOf(0))
whenever(timelineCases.voteInPoll("4", poll.id, listOf(0)))
.thenReturn(Single.just(votedPoll))
runBlocking {
viewModel.voteInPoll(1, listOf(0)).join()
}
verify(timelineCases).voteInPoll("4", poll.id, listOf(0))
assertHasList(listOf(status5, status4.copy(poll = votedPoll), status3).toViewData())
}
private fun setLoadAbove(
above: String,
aboveMinusOne: String? = null,
items: List<TimelineStatus>
) {
whenever(
timelineRepository.getStatuses(
null,
above,
aboveMinusOne,
LOAD_AT_ONCE,
TimelineRequestMode.NETWORK
)
).thenReturn(Single.just(items))
}
private fun assertHasList(aList: List<StatusViewData>) {
assertEquals(
aList,
viewModel.statuses.toList()
)
}
private fun assertViewUpdated(updates: @NonNull TestObserver<Unit>) {
assertTrue("There were view updates", updates.values().isNotEmpty())
}
private fun setInitialRefresh(maxId: String?, statuses: List<Status>) {
setInitialRefreshWithGaps(maxId, statuses.toEitherList())
}
private fun setCachedResponse(initialResponse: List<Status>) {
setCachedResponseWithGaps(initialResponse.toEitherList())
}
private fun setCachedResponseWithGaps(initialResponse: List<TimelineStatus>) {
whenever(
timelineRepository.getStatuses(
isNull(),
isNull(),
isNull(),
eq(LOAD_AT_ONCE),
eq(TimelineRequestMode.DISK)
)
)
.thenReturn(Single.just(initialResponse))
}
private fun setInitialRefreshWithGaps(maxId: String?, statuses: List<TimelineStatus>) {
whenever(
timelineRepository.getStatuses(
maxId,
null,
null,
LOAD_AT_ONCE,
TimelineRequestMode.NETWORK
)
).thenReturn(Single.just(statuses))
}
private fun List<Status>.toViewData(): List<StatusViewData> = map {
it.toViewData(
alwaysShowSensitiveMedia = false,
alwaysOpenSpoiler = false
)
}
private fun List<Status>.toEitherList() = map { Either.Right<Placeholder, Status>(it) }
}