Yuito-app-android/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediato...

469 lines
15 KiB
Kotlin
Raw Normal View History

Timeline paging (#2238) * first setup * network timeline paging / improvements * rename classes / move to correct package * remove unused class TimelineAdapter * some code cleanup * remove TimelineRepository, put mapper functions in TimelineTypeMappers.kt * add db migration * cleanup unused code * bugfix * make default timeline settings work again * fix pinning statuses from timeline * fix network timeline * respect account settings in NetworkTimelineRemoteMediator * respect account settings in NetworkTimelineRemoteMediator * update license headers * show error view when an error occurs * cleanup some todos * fix db migration * fix changing mediaPreviewEnabled setting * fix "load more" button appearing on top of timeline * fix filtering and other bugs * cleanup cache after 14 days * fix TimelineDAOTest * fix code formatting * add NetworkTimeline unit tests * add CachedTimeline unit tests * fix code formatting * move TimelineDaoTest to unit tests * implement removeAllByInstance for CachedTimelineViewModel * fix code formatting * fix bug in TimelineDao.deleteAllFromInstance * improve loading more statuses in NetworkTimelineViewModel * improve loading more statuses in NetworkTimelineViewModel * fix bug where empty state was shown too soon * reload top of cached timeline on app start * improve CachedTimelineRemoteMediator and Tests * improve cached timeline tests * fix some more todos * implement TimelineFragment.removeItem * fix ListStatusAccessibilityDelegate * fix crash in NetworkTimelineViewModel.loadMore * fix default state of collapsible statuses * fix default state of collapsible statuses -tests * fix showing/hiding media in the timeline * get rid of some not-null assertion operators in TimelineTypeMappers * fix tests * error handling in CachedTimelineViewModel.loadMore * keep local status state when refreshing cached statuses * keep local status state when refreshing network timeline statuses * show placeholder loading state in cached timeline * better comments, some code cleanup * add TimelineViewModelTest, improve code, fix bug * fix ktlint * fix voting in boosted polls * code improvement
2022-01-11 19:00:29 +01:00
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 com.nhaarman.mockitokotlin2.anyOrNull
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
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.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
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`() {
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(
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"),
TimelineStatusWithAccount().apply {
status = Placeholder("4", loading = false).toEntity(1)
},
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1"),
)
)
}
@Test
@ExperimentalPagingApi
fun `should refresh and not insert placeholders`() {
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("6"),
mockStatus("4"),
mockStatus("3")
)
)
)
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(
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("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 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()) = PagingState(
pages = pages,
anchorPosition = null,
config = PagingConfig(
pageSize = 20
),
leadingPlaceholderCount = 0
)
private fun AppDatabase.insert(statuses: List<TimelineStatusWithAccount>) {
runBlocking {
statuses.forEach { statusWithAccount ->
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().getStatusesForAccount(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)
}
}
}
}