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:
parent
0a992480c2
commit
44a5b42cac
58 changed files with 3956 additions and 3618 deletions
|
@ -91,7 +91,7 @@ class BottomSheetActivityTest {
|
|||
"",
|
||||
Status.Visibility.PUBLIC,
|
||||
ArrayList(),
|
||||
arrayOf(),
|
||||
listOf(),
|
||||
null,
|
||||
pinned = false,
|
||||
muted = false,
|
||||
|
|
|
@ -1,260 +1,186 @@
|
|||
package com.keylesspalace.tusky
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.SpannedString
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.PollOption
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.fragment.SFragment
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.nhaarman.mockitokotlin2.doReturn
|
||||
import com.nhaarman.mockitokotlin2.mock
|
||||
import okhttp3.Request
|
||||
import okio.Timeout
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
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.Mockito
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.annotation.Config
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import java.util.*
|
||||
|
||||
@Config(sdk = [28])
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FilterTest {
|
||||
|
||||
private val fragment = FakeFragment()
|
||||
lateinit var filterModel: FilterModel
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
filterModel = FilterModel()
|
||||
val filters = listOf(
|
||||
Filter(
|
||||
id = "123",
|
||||
phrase = "badWord",
|
||||
context = listOf(Filter.HOME),
|
||||
expiresAt = null,
|
||||
irreversible = false,
|
||||
wholeWord = false
|
||||
),
|
||||
Filter(
|
||||
id = "123",
|
||||
phrase = "badWholeWord",
|
||||
context = listOf(Filter.HOME, Filter.PUBLIC),
|
||||
expiresAt = null,
|
||||
irreversible = false,
|
||||
wholeWord = true
|
||||
),
|
||||
Filter(
|
||||
id = "123",
|
||||
phrase = "@twitter.com",
|
||||
context = listOf(Filter.HOME),
|
||||
expiresAt = null,
|
||||
irreversible = false,
|
||||
wholeWord = true
|
||||
)
|
||||
)
|
||||
|
||||
val controller = Robolectric.buildActivity(FakeActivity::class.java)
|
||||
val activity = controller.get()
|
||||
|
||||
activity.accountManager = mock()
|
||||
val apiMock = Mockito.mock(MastodonApi::class.java)
|
||||
Mockito.`when`(apiMock.getFilters()).thenReturn(object: Call<List<Filter>> {
|
||||
override fun isExecuted(): Boolean {
|
||||
return false
|
||||
}
|
||||
override fun clone(): Call<List<Filter>> {
|
||||
throw Error("not implemented")
|
||||
}
|
||||
override fun isCanceled(): Boolean {
|
||||
throw Error("not implemented")
|
||||
}
|
||||
override fun cancel() {
|
||||
throw Error("not implemented")
|
||||
}
|
||||
override fun execute(): Response<List<Filter>> {
|
||||
throw Error("not implemented")
|
||||
}
|
||||
override fun request(): Request {
|
||||
throw Error("not implemented")
|
||||
}
|
||||
|
||||
override fun enqueue(callback: Callback<List<Filter>>) {
|
||||
callback.onResponse(
|
||||
this,
|
||||
Response.success(
|
||||
listOf(
|
||||
Filter(
|
||||
id = "123",
|
||||
phrase = "badWord",
|
||||
context = listOf(Filter.HOME),
|
||||
expiresAt = null,
|
||||
irreversible = false,
|
||||
wholeWord = false
|
||||
),
|
||||
Filter(
|
||||
id = "123",
|
||||
phrase = "badWholeWord",
|
||||
context = listOf(Filter.HOME, Filter.PUBLIC),
|
||||
expiresAt = null,
|
||||
irreversible = false,
|
||||
wholeWord = true
|
||||
),
|
||||
Filter(
|
||||
id = "123",
|
||||
phrase = "wrongContext",
|
||||
context = listOf(Filter.PUBLIC),
|
||||
expiresAt = null,
|
||||
irreversible = false,
|
||||
wholeWord = true
|
||||
),
|
||||
Filter(
|
||||
id = "123",
|
||||
phrase = "@twitter.com",
|
||||
context = listOf(Filter.HOME),
|
||||
expiresAt = null,
|
||||
irreversible = false,
|
||||
wholeWord = true
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun timeout(): Timeout {
|
||||
throw Error("not implemented")
|
||||
}
|
||||
})
|
||||
|
||||
activity.mastodonApi = apiMock
|
||||
|
||||
|
||||
controller.create().start()
|
||||
|
||||
fragment.mastodonApi = apiMock
|
||||
|
||||
|
||||
activity.supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.mainDrawerLayout, fragment, "fragment")
|
||||
.commit()
|
||||
|
||||
fragment.reloadFilters(false)
|
||||
|
||||
filterModel.initWithFilters(filters)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldNotFilter() {
|
||||
assertFalse(fragment.shouldFilterStatus(
|
||||
assertFalse(
|
||||
filterModel.shouldFilterStatus(
|
||||
mockStatus(content = "should not be filtered")
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldNotFilter_whenContextDoesNotMatch() {
|
||||
assertFalse(fragment.shouldFilterStatus(
|
||||
mockStatus(content = "one two wrongContext three")
|
||||
))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldFilter_whenContentMatchesBadWord() {
|
||||
assertTrue(fragment.shouldFilterStatus(
|
||||
assertTrue(
|
||||
filterModel.shouldFilterStatus(
|
||||
mockStatus(content = "one two badWord three")
|
||||
))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldFilter_whenContentMatchesBadWordPart() {
|
||||
assertTrue(fragment.shouldFilterStatus(
|
||||
assertTrue(
|
||||
filterModel.shouldFilterStatus(
|
||||
mockStatus(content = "one two badWordPart three")
|
||||
))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldFilter_whenContentMatchesBadWholeWord() {
|
||||
assertTrue(fragment.shouldFilterStatus(
|
||||
assertTrue(
|
||||
filterModel.shouldFilterStatus(
|
||||
mockStatus(content = "one two badWholeWord three")
|
||||
))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldNotFilter_whenContentDoesNotMatchWholeWord() {
|
||||
assertFalse(fragment.shouldFilterStatus(
|
||||
assertFalse(
|
||||
filterModel.shouldFilterStatus(
|
||||
mockStatus(content = "one two badWholeWordTest three")
|
||||
))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldFilter_whenSpoilerTextDoesMatch() {
|
||||
assertTrue(fragment.shouldFilterStatus(
|
||||
assertTrue(
|
||||
filterModel.shouldFilterStatus(
|
||||
mockStatus(
|
||||
content = "should not be filtered",
|
||||
spoilerText = "badWord should be filtered"
|
||||
content = "should not be filtered",
|
||||
spoilerText = "badWord should be filtered"
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldFilter_whenPollTextDoesMatch() {
|
||||
assertTrue(fragment.shouldFilterStatus(
|
||||
assertTrue(
|
||||
filterModel.shouldFilterStatus(
|
||||
mockStatus(
|
||||
content = "should not be filtered",
|
||||
spoilerText = "should not be filtered",
|
||||
pollOptions = listOf("should not be filtered", "badWord")
|
||||
content = "should not be filtered",
|
||||
spoilerText = "should not be filtered",
|
||||
pollOptions = listOf("should not be filtered", "badWord")
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldFilterPartialWord_whenWholeWordFilterContainsNonAlphanumericCharacters() {
|
||||
assertTrue(fragment.shouldFilterStatus(
|
||||
assertTrue(
|
||||
filterModel.shouldFilterStatus(
|
||||
mockStatus(content = "one two someone@twitter.com three")
|
||||
))
|
||||
}
|
||||
|
||||
private fun mockStatus(
|
||||
content: String = "",
|
||||
spoilerText: String = "",
|
||||
pollOptions: List<String>? = null
|
||||
): Status {
|
||||
return Status(
|
||||
id = "123",
|
||||
url = "https://mastodon.social/@Tusky/100571663297225812",
|
||||
account = mock(),
|
||||
inReplyToId = null,
|
||||
inReplyToAccountId = null,
|
||||
reblog = null,
|
||||
content = SpannedString(content),
|
||||
createdAt = Date(),
|
||||
emojis = emptyList(),
|
||||
reblogsCount = 0,
|
||||
favouritesCount = 0,
|
||||
reblogged = false,
|
||||
favourited = false,
|
||||
bookmarked = false,
|
||||
sensitive = false,
|
||||
spoilerText = spoilerText,
|
||||
visibility = Status.Visibility.PUBLIC,
|
||||
attachments = arrayListOf(),
|
||||
mentions = emptyArray(),
|
||||
application = null,
|
||||
pinned = false,
|
||||
muted = false,
|
||||
poll = if (pollOptions != null) {
|
||||
Poll(
|
||||
id = "1234",
|
||||
expiresAt = null,
|
||||
expired = false,
|
||||
multiple = false,
|
||||
votesCount = 0,
|
||||
votersCount = 0,
|
||||
options = pollOptions.map {
|
||||
PollOption(it, 0)
|
||||
},
|
||||
voted = false
|
||||
)
|
||||
} else null,
|
||||
card = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class FakeActivity: BottomSheetActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeFragment: SFragment() {
|
||||
override fun removeItem(position: Int) {
|
||||
private fun mockStatus(
|
||||
content: String = "",
|
||||
spoilerText: String = "",
|
||||
pollOptions: List<String>? = null
|
||||
): Status {
|
||||
return Status(
|
||||
id = "123",
|
||||
url = "https://mastodon.social/@Tusky/100571663297225812",
|
||||
account = mock(),
|
||||
inReplyToId = null,
|
||||
inReplyToAccountId = null,
|
||||
reblog = null,
|
||||
content = SpannedString(content),
|
||||
createdAt = Date(),
|
||||
emojis = emptyList(),
|
||||
reblogsCount = 0,
|
||||
favouritesCount = 0,
|
||||
reblogged = false,
|
||||
favourited = false,
|
||||
bookmarked = false,
|
||||
sensitive = false,
|
||||
spoilerText = spoilerText,
|
||||
visibility = Status.Visibility.PUBLIC,
|
||||
attachments = arrayListOf(),
|
||||
mentions = listOf(),
|
||||
application = null,
|
||||
pinned = false,
|
||||
muted = false,
|
||||
poll = if (pollOptions != null) {
|
||||
Poll(
|
||||
id = "1234",
|
||||
expiresAt = null,
|
||||
expired = false,
|
||||
multiple = false,
|
||||
votesCount = 0,
|
||||
votersCount = 0,
|
||||
options = pollOptions.map {
|
||||
PollOption(it, 0)
|
||||
},
|
||||
voted = false
|
||||
)
|
||||
} else null,
|
||||
card = null
|
||||
)
|
||||
}
|
||||
|
||||
override fun onReblog(reblog: Boolean, position: Int) {
|
||||
}
|
||||
|
||||
override fun filterIsRelevant(filter: Filter): Boolean {
|
||||
return filter.context.contains(Filter.HOME)
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package com.keylesspalace.tusky.fragment
|
||||
package com.keylesspalace.tusky.components.timeline
|
||||
|
||||
import android.text.SpannableString
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
@ -10,7 +10,6 @@ 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.repository.*
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.nhaarman.mockitokotlin2.isNull
|
||||
import com.nhaarman.mockitokotlin2.verify
|
||||
|
@ -54,10 +53,10 @@ class TimelineRepositoryTest {
|
|||
|
||||
private val limit = 30
|
||||
private val account = AccountEntity(
|
||||
id = 2,
|
||||
accessToken = "token",
|
||||
domain = "domain.com",
|
||||
isActive = true
|
||||
id = 2,
|
||||
accessToken = "token",
|
||||
domain = "domain.com",
|
||||
isActive = true
|
||||
)
|
||||
|
||||
@Before
|
||||
|
@ -74,13 +73,13 @@ class TimelineRepositoryTest {
|
|||
@Test
|
||||
fun testNetworkUnbounded() {
|
||||
val statuses = listOf(
|
||||
makeStatus("3"),
|
||||
makeStatus("2")
|
||||
makeStatus("3"),
|
||||
makeStatus("2")
|
||||
)
|
||||
whenever(mastodonApi.homeTimeline(isNull(), isNull(), anyInt()))
|
||||
.thenReturn(Single.just(Response.success(statuses)))
|
||||
.thenReturn(Single.just(Response.success(statuses)))
|
||||
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.NETWORK)
|
||||
.blockingGet()
|
||||
.blockingGet()
|
||||
|
||||
assertEquals(statuses.map(Status::lift), result)
|
||||
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
|
||||
|
@ -90,9 +89,9 @@ class TimelineRepositoryTest {
|
|||
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
|
||||
status.toEntity(account.id, gson),
|
||||
status.account.toEntity(account.id, gson),
|
||||
null
|
||||
)
|
||||
}
|
||||
verify(timelineDao).cleanup(anyLong())
|
||||
|
@ -102,34 +101,38 @@ class TimelineRepositoryTest {
|
|||
@Test
|
||||
fun testNetworkLoadingTopNoGap() {
|
||||
val response = listOf(
|
||||
makeStatus("4"),
|
||||
makeStatus("3"),
|
||||
makeStatus("2")
|
||||
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()
|
||||
.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
|
||||
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
|
||||
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).removeAllPlaceholdersBetween(
|
||||
account.id, response.first().id,
|
||||
response.last().id
|
||||
)
|
||||
verify(timelineDao).cleanup(anyLong())
|
||||
verifyNoMoreInteractions(timelineDao)
|
||||
}
|
||||
|
@ -137,16 +140,18 @@ class TimelineRepositoryTest {
|
|||
@Test
|
||||
fun testNetworkLoadingTopWithGap() {
|
||||
val response = listOf(
|
||||
makeStatus("5"),
|
||||
makeStatus("4")
|
||||
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()
|
||||
.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)
|
||||
|
@ -154,9 +159,9 @@ class TimelineRepositoryTest {
|
|||
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
|
||||
status.toEntity(account.id, gson),
|
||||
status.account.toEntity(account.id, gson),
|
||||
null
|
||||
)
|
||||
}
|
||||
verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id))
|
||||
|
@ -174,36 +179,40 @@ class TimelineRepositoryTest {
|
|||
// 1
|
||||
|
||||
val response = listOf(
|
||||
makeStatus("5"),
|
||||
makeStatus("4"),
|
||||
makeStatus("3"),
|
||||
makeStatus("2")
|
||||
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()
|
||||
.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
|
||||
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
|
||||
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).removeAllPlaceholdersBetween(
|
||||
account.id, response.first().id,
|
||||
response.last().id
|
||||
)
|
||||
verify(timelineDao).cleanup(anyLong())
|
||||
verifyNoMoreInteractions(timelineDao)
|
||||
}
|
||||
|
@ -218,23 +227,25 @@ class TimelineRepositoryTest {
|
|||
// 1
|
||||
|
||||
val response = listOf(
|
||||
makeStatus("6"),
|
||||
makeStatus("5"),
|
||||
makeStatus("4")
|
||||
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()
|
||||
.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
|
||||
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
|
||||
|
@ -243,13 +254,15 @@ class TimelineRepositoryTest {
|
|||
|
||||
for (status in response) {
|
||||
verify(timelineDao).insertInTransaction(
|
||||
status.toEntity(account.id, gson),
|
||||
status.account.toEntity(account.id, gson),
|
||||
null
|
||||
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).removeAllPlaceholdersBetween(
|
||||
account.id, response.first().id,
|
||||
response.last().id
|
||||
)
|
||||
verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id))
|
||||
verify(timelineDao).cleanup(anyLong())
|
||||
verifyNoMoreInteractions(timelineDao)
|
||||
|
@ -265,11 +278,11 @@ class TimelineRepositoryTest {
|
|||
dbResult.account = status.account.toEntity(account.id, gson)
|
||||
|
||||
whenever(mastodonApi.homeTimeline(any(), any(), any()))
|
||||
.thenReturn(Single.just(Response.success((listOf(status)))))
|
||||
.thenReturn(Single.just(Response.success((listOf(status)))))
|
||||
whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30))
|
||||
.thenReturn(Single.just(listOf(dbResult)))
|
||||
.thenReturn(Single.just(listOf(dbResult)))
|
||||
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY)
|
||||
.blockingGet()
|
||||
.blockingGet()
|
||||
assertEquals(listOf(status, dbStatus).map(Status::lift), result)
|
||||
}
|
||||
|
||||
|
@ -283,60 +296,60 @@ class TimelineRepositoryTest {
|
|||
dbResult2.status = Placeholder("1").toEntity(account.id)
|
||||
|
||||
whenever(mastodonApi.homeTimeline(any(), any(), any()))
|
||||
.thenReturn(Single.just(Response.success(listOf(status))))
|
||||
.thenReturn(Single.just(Response.success(listOf(status))))
|
||||
whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30))
|
||||
.thenReturn(Single.just(listOf(dbResult, dbResult2)))
|
||||
.thenReturn(Single.just(listOf(dbResult, dbResult2)))
|
||||
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY)
|
||||
.blockingGet()
|
||||
.blockingGet()
|
||||
assertEquals(listOf(status).map(Status::lift), result)
|
||||
}
|
||||
}
|
||||
|
||||
private 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 = arrayOf(),
|
||||
application = null,
|
||||
inReplyToAccountId = null,
|
||||
inReplyToId = null,
|
||||
pinned = false,
|
||||
muted = false,
|
||||
reblog = null,
|
||||
url = "http://example.com/statuses/$id",
|
||||
poll = null,
|
||||
card = null
|
||||
)
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
private 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
|
||||
)
|
||||
}
|
|
@ -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) }
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue