605 lines
19 KiB
Kotlin
605 lines
19 KiB
Kotlin
package com.keylesspalace.tusky.components.timeline
|
|
|
|
import android.os.Looper.getMainLooper
|
|
import androidx.paging.ExperimentalPagingApi
|
|
import androidx.paging.LoadType
|
|
import androidx.paging.PagingConfig
|
|
import androidx.paging.PagingSource
|
|
import androidx.paging.PagingState
|
|
import androidx.paging.RemoteMediator
|
|
import androidx.room.Room
|
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
import androidx.test.platform.app.InstrumentationRegistry
|
|
import com.google.gson.Gson
|
|
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineRemoteMediator
|
|
import com.keylesspalace.tusky.db.AccountEntity
|
|
import com.keylesspalace.tusky.db.AccountManager
|
|
import com.keylesspalace.tusky.db.AppDatabase
|
|
import com.keylesspalace.tusky.db.Converters
|
|
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
|
|
import io.reactivex.rxjava3.core.Single
|
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
import kotlinx.coroutines.runBlocking
|
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
|
import org.junit.After
|
|
import org.junit.Assert.assertEquals
|
|
import org.junit.Assert.assertFalse
|
|
import org.junit.Assert.assertTrue
|
|
import org.junit.Before
|
|
import org.junit.Test
|
|
import org.junit.runner.RunWith
|
|
import org.mockito.kotlin.anyOrNull
|
|
import org.mockito.kotlin.doReturn
|
|
import org.mockito.kotlin.mock
|
|
import org.robolectric.Shadows.shadowOf
|
|
import org.robolectric.annotation.Config
|
|
import retrofit2.HttpException
|
|
import retrofit2.Response
|
|
import java.io.IOException
|
|
|
|
@Config(sdk = [28])
|
|
@RunWith(AndroidJUnit4::class)
|
|
class CachedTimelineRemoteMediatorTest {
|
|
|
|
private val accountManager: AccountManager = mock {
|
|
on { activeAccount } doReturn AccountEntity(
|
|
id = 1,
|
|
domain = "mastodon.example",
|
|
accessToken = "token",
|
|
isActive = true
|
|
)
|
|
}
|
|
|
|
private lateinit var db: AppDatabase
|
|
|
|
@Before
|
|
@ExperimentalCoroutinesApi
|
|
fun setup() {
|
|
shadowOf(getMainLooper()).idle()
|
|
|
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
|
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
|
|
.addTypeConverter(Converters(Gson()))
|
|
.build()
|
|
}
|
|
|
|
@After
|
|
@ExperimentalCoroutinesApi
|
|
fun tearDown() {
|
|
db.close()
|
|
}
|
|
|
|
@Test
|
|
@ExperimentalPagingApi
|
|
fun `should return error when network call returns error code`() {
|
|
|
|
val remoteMediator = CachedTimelineRemoteMediator(
|
|
accountManager = accountManager,
|
|
api = mock {
|
|
on { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Single.just(Response.error(500, "".toResponseBody()))
|
|
},
|
|
db = db,
|
|
gson = Gson()
|
|
)
|
|
|
|
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) }
|
|
|
|
assertTrue(result is RemoteMediator.MediatorResult.Error)
|
|
assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is HttpException)
|
|
assertEquals(500, (result.throwable as HttpException).code())
|
|
}
|
|
|
|
@Test
|
|
@ExperimentalPagingApi
|
|
fun `should return error when network call fails`() {
|
|
|
|
val remoteMediator = CachedTimelineRemoteMediator(
|
|
accountManager = accountManager,
|
|
api = mock {
|
|
on { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Single.error(IOException())
|
|
},
|
|
db = db,
|
|
gson = Gson()
|
|
)
|
|
|
|
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) }
|
|
|
|
assertTrue(result is RemoteMediator.MediatorResult.Error)
|
|
assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is IOException)
|
|
}
|
|
|
|
@Test
|
|
@ExperimentalPagingApi
|
|
fun `should not prepend statuses`() {
|
|
|
|
val remoteMediator = CachedTimelineRemoteMediator(
|
|
accountManager = accountManager,
|
|
api = mock(),
|
|
db = db,
|
|
gson = Gson()
|
|
)
|
|
|
|
val state = state(
|
|
listOf(
|
|
PagingSource.LoadResult.Page(
|
|
data = listOf(
|
|
mockStatusEntityWithAccount("3")
|
|
),
|
|
prevKey = null,
|
|
nextKey = 1
|
|
)
|
|
)
|
|
)
|
|
|
|
val result = runBlocking { remoteMediator.load(LoadType.PREPEND, state) }
|
|
|
|
assertTrue(result is RemoteMediator.MediatorResult.Success)
|
|
assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
|
|
}
|
|
|
|
@Test
|
|
@ExperimentalPagingApi
|
|
fun `should refresh and insert placeholder when a whole page with no overlap to existing statuses is loaded`() {
|
|
|
|
val statusesAlreadyInDb = listOf(
|
|
mockStatusEntityWithAccount("3"),
|
|
mockStatusEntityWithAccount("2"),
|
|
mockStatusEntityWithAccount("1"),
|
|
)
|
|
|
|
db.insert(statusesAlreadyInDb)
|
|
|
|
val remoteMediator = CachedTimelineRemoteMediator(
|
|
accountManager = accountManager,
|
|
api = mock {
|
|
on { homeTimeline(limit = 3) } doReturn Single.just(
|
|
Response.success(
|
|
listOf(
|
|
mockStatus("8"),
|
|
mockStatus("7"),
|
|
mockStatus("5")
|
|
)
|
|
)
|
|
)
|
|
on { homeTimeline(maxId = "3", limit = 3) } doReturn Single.just(
|
|
Response.success(
|
|
listOf(
|
|
mockStatus("3"),
|
|
mockStatus("2"),
|
|
mockStatus("1")
|
|
)
|
|
)
|
|
)
|
|
},
|
|
db = db,
|
|
gson = Gson()
|
|
)
|
|
|
|
val state = state(
|
|
pages = listOf(
|
|
PagingSource.LoadResult.Page(
|
|
data = statusesAlreadyInDb,
|
|
prevKey = null,
|
|
nextKey = 0
|
|
)
|
|
),
|
|
pageSize = 3
|
|
)
|
|
|
|
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
|
|
|
|
assertTrue(result is RemoteMediator.MediatorResult.Success)
|
|
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
|
|
|
|
db.assertStatuses(
|
|
listOf(
|
|
mockStatusEntityWithAccount("8"),
|
|
mockStatusEntityWithAccount("7"),
|
|
TimelineStatusWithAccount().apply {
|
|
status = Placeholder("5", loading = false).toEntity(1)
|
|
},
|
|
mockStatusEntityWithAccount("3"),
|
|
mockStatusEntityWithAccount("2"),
|
|
mockStatusEntityWithAccount("1"),
|
|
)
|
|
)
|
|
}
|
|
|
|
@Test
|
|
@ExperimentalPagingApi
|
|
fun `should refresh and not insert placeholder when less than a whole page is loaded`() {
|
|
|
|
val statusesAlreadyInDb = listOf(
|
|
mockStatusEntityWithAccount("3"),
|
|
mockStatusEntityWithAccount("2"),
|
|
mockStatusEntityWithAccount("1"),
|
|
)
|
|
|
|
db.insert(statusesAlreadyInDb)
|
|
|
|
val remoteMediator = CachedTimelineRemoteMediator(
|
|
accountManager = accountManager,
|
|
api = mock {
|
|
on { homeTimeline(limit = 20) } doReturn Single.just(
|
|
Response.success(
|
|
listOf(
|
|
mockStatus("8"),
|
|
mockStatus("7"),
|
|
mockStatus("5")
|
|
)
|
|
)
|
|
)
|
|
on { homeTimeline(maxId = "3", limit = 20) } doReturn Single.just(
|
|
Response.success(
|
|
listOf(
|
|
mockStatus("3"),
|
|
mockStatus("2"),
|
|
mockStatus("1")
|
|
)
|
|
)
|
|
)
|
|
},
|
|
db = db,
|
|
gson = Gson()
|
|
)
|
|
|
|
val state = state(
|
|
pages = listOf(
|
|
PagingSource.LoadResult.Page(
|
|
data = statusesAlreadyInDb,
|
|
prevKey = null,
|
|
nextKey = 0
|
|
)
|
|
)
|
|
)
|
|
|
|
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
|
|
|
|
assertTrue(result is RemoteMediator.MediatorResult.Success)
|
|
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
|
|
|
|
db.assertStatuses(
|
|
listOf(
|
|
mockStatusEntityWithAccount("8"),
|
|
mockStatusEntityWithAccount("7"),
|
|
mockStatusEntityWithAccount("5"),
|
|
mockStatusEntityWithAccount("3"),
|
|
mockStatusEntityWithAccount("2"),
|
|
mockStatusEntityWithAccount("1"),
|
|
)
|
|
)
|
|
}
|
|
|
|
@Test
|
|
@ExperimentalPagingApi
|
|
fun `should refresh and not insert placeholders when there is overlap with existing statuses`() {
|
|
|
|
val statusesAlreadyInDb = listOf(
|
|
mockStatusEntityWithAccount("3"),
|
|
mockStatusEntityWithAccount("2"),
|
|
mockStatusEntityWithAccount("1"),
|
|
)
|
|
|
|
db.insert(statusesAlreadyInDb)
|
|
|
|
val remoteMediator = CachedTimelineRemoteMediator(
|
|
accountManager = accountManager,
|
|
api = mock {
|
|
on { homeTimeline(limit = 3) } doReturn Single.just(
|
|
Response.success(
|
|
listOf(
|
|
mockStatus("6"),
|
|
mockStatus("4"),
|
|
mockStatus("3")
|
|
)
|
|
)
|
|
)
|
|
on { homeTimeline(maxId = "3", limit = 3) } doReturn Single.just(
|
|
Response.success(
|
|
listOf(
|
|
mockStatus("3"),
|
|
mockStatus("2"),
|
|
mockStatus("1")
|
|
)
|
|
)
|
|
)
|
|
},
|
|
db = db,
|
|
gson = Gson()
|
|
)
|
|
|
|
val state = state(
|
|
listOf(
|
|
PagingSource.LoadResult.Page(
|
|
data = statusesAlreadyInDb,
|
|
prevKey = null,
|
|
nextKey = 0
|
|
)
|
|
),
|
|
pageSize = 3
|
|
)
|
|
|
|
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
|
|
|
|
assertTrue(result is RemoteMediator.MediatorResult.Success)
|
|
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
|
|
|
|
db.assertStatuses(
|
|
listOf(
|
|
mockStatusEntityWithAccount("6"),
|
|
mockStatusEntityWithAccount("4"),
|
|
mockStatusEntityWithAccount("3"),
|
|
mockStatusEntityWithAccount("2"),
|
|
mockStatusEntityWithAccount("1"),
|
|
)
|
|
)
|
|
}
|
|
|
|
@Test
|
|
@ExperimentalPagingApi
|
|
fun `should not try to refresh already cached statuses when db is empty`() {
|
|
|
|
val remoteMediator = CachedTimelineRemoteMediator(
|
|
accountManager = accountManager,
|
|
api = mock {
|
|
on { homeTimeline(limit = 20) } doReturn Single.just(
|
|
Response.success(
|
|
listOf(
|
|
mockStatus("5"),
|
|
mockStatus("4"),
|
|
mockStatus("3")
|
|
)
|
|
)
|
|
)
|
|
},
|
|
db = db,
|
|
gson = Gson()
|
|
)
|
|
|
|
val state = state(
|
|
listOf(
|
|
PagingSource.LoadResult.Page(
|
|
data = emptyList(),
|
|
prevKey = null,
|
|
nextKey = 0
|
|
)
|
|
)
|
|
)
|
|
|
|
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
|
|
|
|
assertTrue(result is RemoteMediator.MediatorResult.Success)
|
|
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
|
|
|
|
db.assertStatuses(
|
|
listOf(
|
|
mockStatusEntityWithAccount("5"),
|
|
mockStatusEntityWithAccount("4"),
|
|
mockStatusEntityWithAccount("3")
|
|
)
|
|
)
|
|
}
|
|
|
|
@Test
|
|
@ExperimentalPagingApi
|
|
fun `should remove deleted status from db and keep state of other cached statuses`() {
|
|
|
|
val statusesAlreadyInDb = listOf(
|
|
mockStatusEntityWithAccount("3", expanded = true),
|
|
mockStatusEntityWithAccount("2"),
|
|
mockStatusEntityWithAccount("1", expanded = false),
|
|
)
|
|
|
|
db.insert(statusesAlreadyInDb)
|
|
|
|
val remoteMediator = CachedTimelineRemoteMediator(
|
|
accountManager = accountManager,
|
|
api = mock {
|
|
on { homeTimeline(limit = 20) } doReturn Single.just(
|
|
Response.success(emptyList())
|
|
)
|
|
on { homeTimeline(maxId = "3", limit = 20) } doReturn Single.just(
|
|
Response.success(
|
|
listOf(
|
|
mockStatus("3"),
|
|
mockStatus("1")
|
|
)
|
|
)
|
|
)
|
|
},
|
|
db = db,
|
|
gson = Gson()
|
|
)
|
|
|
|
val state = state(
|
|
listOf(
|
|
PagingSource.LoadResult.Page(
|
|
data = statusesAlreadyInDb,
|
|
prevKey = null,
|
|
nextKey = 0
|
|
)
|
|
)
|
|
)
|
|
|
|
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
|
|
|
|
assertTrue(result is RemoteMediator.MediatorResult.Success)
|
|
assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
|
|
|
|
db.assertStatuses(
|
|
listOf(
|
|
mockStatusEntityWithAccount("3", expanded = true),
|
|
mockStatusEntityWithAccount("1", expanded = false)
|
|
)
|
|
)
|
|
}
|
|
|
|
@Test
|
|
@ExperimentalPagingApi
|
|
fun `should not remove placeholder in timeline`() {
|
|
|
|
val statusesAlreadyInDb = listOf(
|
|
mockStatusEntityWithAccount("8"),
|
|
mockStatusEntityWithAccount("7"),
|
|
mockPlaceholderEntityWithAccount("6"),
|
|
mockStatusEntityWithAccount("1"),
|
|
)
|
|
|
|
db.insert(statusesAlreadyInDb)
|
|
|
|
val remoteMediator = CachedTimelineRemoteMediator(
|
|
accountManager = accountManager,
|
|
api = mock {
|
|
on { homeTimeline(sinceId = "6", limit = 20) } doReturn Single.just(
|
|
Response.success(
|
|
listOf(
|
|
mockStatus("9"),
|
|
mockStatus("8"),
|
|
mockStatus("7")
|
|
)
|
|
)
|
|
)
|
|
on { homeTimeline(maxId = "8", sinceId = "6", limit = 20) } doReturn Single.just(
|
|
Response.success(
|
|
listOf(
|
|
mockStatus("8"),
|
|
mockStatus("7")
|
|
)
|
|
)
|
|
)
|
|
},
|
|
db = db,
|
|
gson = Gson()
|
|
)
|
|
|
|
val state = state(
|
|
listOf(
|
|
PagingSource.LoadResult.Page(
|
|
data = statusesAlreadyInDb,
|
|
prevKey = null,
|
|
nextKey = 0
|
|
)
|
|
)
|
|
)
|
|
|
|
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
|
|
|
|
assertTrue(result is RemoteMediator.MediatorResult.Success)
|
|
assertFalse((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
|
|
|
|
db.assertStatuses(
|
|
listOf(
|
|
mockStatusEntityWithAccount("9"),
|
|
mockStatusEntityWithAccount("8"),
|
|
mockStatusEntityWithAccount("7"),
|
|
mockPlaceholderEntityWithAccount("6"),
|
|
mockStatusEntityWithAccount("1"),
|
|
)
|
|
)
|
|
}
|
|
|
|
@Test
|
|
@ExperimentalPagingApi
|
|
fun `should append statuses`() {
|
|
|
|
val statusesAlreadyInDb = listOf(
|
|
mockStatusEntityWithAccount("8"),
|
|
mockStatusEntityWithAccount("7"),
|
|
mockStatusEntityWithAccount("5"),
|
|
)
|
|
|
|
db.insert(statusesAlreadyInDb)
|
|
|
|
val remoteMediator = CachedTimelineRemoteMediator(
|
|
accountManager = accountManager,
|
|
api = mock {
|
|
on { homeTimeline(maxId = "5", limit = 20) } doReturn Single.just(
|
|
Response.success(
|
|
listOf(
|
|
mockStatus("3"),
|
|
mockStatus("2"),
|
|
mockStatus("1")
|
|
)
|
|
)
|
|
)
|
|
},
|
|
db = db,
|
|
gson = Gson()
|
|
)
|
|
|
|
val state = state(
|
|
listOf(
|
|
PagingSource.LoadResult.Page(
|
|
data = statusesAlreadyInDb,
|
|
prevKey = null,
|
|
nextKey = 0
|
|
)
|
|
)
|
|
)
|
|
|
|
val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
|
|
|
|
assertTrue(result is RemoteMediator.MediatorResult.Success)
|
|
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
|
|
db.assertStatuses(
|
|
listOf(
|
|
mockStatusEntityWithAccount("8"),
|
|
mockStatusEntityWithAccount("7"),
|
|
mockStatusEntityWithAccount("5"),
|
|
mockStatusEntityWithAccount("3"),
|
|
mockStatusEntityWithAccount("2"),
|
|
mockStatusEntityWithAccount("1"),
|
|
)
|
|
)
|
|
}
|
|
|
|
private fun state(
|
|
pages: List<PagingSource.LoadResult.Page<Int, TimelineStatusWithAccount>> = emptyList(),
|
|
pageSize: Int = 20
|
|
) = PagingState(
|
|
pages = pages,
|
|
anchorPosition = null,
|
|
config = PagingConfig(
|
|
pageSize = pageSize
|
|
),
|
|
leadingPlaceholderCount = 0
|
|
)
|
|
|
|
private fun AppDatabase.insert(statuses: List<TimelineStatusWithAccount>) {
|
|
runBlocking {
|
|
statuses.forEach { statusWithAccount ->
|
|
if (statusWithAccount.status.authorServerId != null) {
|
|
timelineDao().insertAccount(statusWithAccount.account)
|
|
}
|
|
statusWithAccount.reblogAccount?.let { account ->
|
|
timelineDao().insertAccount(account)
|
|
}
|
|
timelineDao().insertStatus(statusWithAccount.status)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun AppDatabase.assertStatuses(
|
|
expected: List<TimelineStatusWithAccount>,
|
|
forAccount: Long = 1
|
|
) {
|
|
val pagingSource = timelineDao().getStatuses(forAccount)
|
|
|
|
val loadResult = runBlocking {
|
|
pagingSource.load(PagingSource.LoadParams.Refresh(null, 100, false))
|
|
}
|
|
|
|
val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data
|
|
|
|
assertEquals(expected.size, loadedStatuses.size)
|
|
|
|
for ((exp, prov) in expected.zip(loadedStatuses)) {
|
|
assertEquals(exp.status, prov.status)
|
|
if (exp.status.authorServerId != null) { // only check if no placeholder
|
|
assertEquals(exp.account, prov.account)
|
|
assertEquals(exp.reblogAccount, prov.reblogAccount)
|
|
}
|
|
}
|
|
}
|
|
}
|