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
This commit is contained in:
parent
224161caf1
commit
643e012b11
41 changed files with 4019 additions and 3146 deletions
|
@ -0,0 +1,468 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
package com.keylesspalace.tusky.components.timeline
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
|
||||
import com.nhaarman.mockitokotlin2.doReturn
|
||||
import com.nhaarman.mockitokotlin2.mock
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class NetworkTimelinePagingSourceTest {
|
||||
|
||||
private val status = mockStatusViewData()
|
||||
|
||||
private val timelineViewModel: NetworkTimelineViewModel = mock {
|
||||
on { statusData } doReturn mutableListOf(status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return empty list when params are Append`() {
|
||||
val pagingSource = NetworkTimelinePagingSource(timelineViewModel)
|
||||
|
||||
val params = PagingSource.LoadParams.Append("132", 20, false)
|
||||
|
||||
val expectedResult = PagingSource.LoadResult.Page(emptyList(), null, null)
|
||||
|
||||
runBlocking {
|
||||
assertEquals(expectedResult, pagingSource.load(params))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return empty list when params are Prepend`() {
|
||||
val pagingSource = NetworkTimelinePagingSource(timelineViewModel)
|
||||
|
||||
val params = PagingSource.LoadParams.Prepend("132", 20, false)
|
||||
|
||||
val expectedResult = PagingSource.LoadResult.Page(emptyList(), null, null)
|
||||
|
||||
runBlocking {
|
||||
assertEquals(expectedResult, pagingSource.load(params))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return full list when params are Refresh`() {
|
||||
val pagingSource = NetworkTimelinePagingSource(timelineViewModel)
|
||||
|
||||
val params = PagingSource.LoadParams.Refresh<String>(null, 20, false)
|
||||
|
||||
val expectedResult = PagingSource.LoadResult.Page(listOf(status), null, null)
|
||||
|
||||
runBlocking {
|
||||
val result = pagingSource.load(params)
|
||||
assertEquals(expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,293 @@
|
|||
package com.keylesspalace.tusky.components.timeline
|
||||
|
||||
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 com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineRemoteMediator
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import com.nhaarman.mockitokotlin2.anyOrNull
|
||||
import com.nhaarman.mockitokotlin2.doReturn
|
||||
import com.nhaarman.mockitokotlin2.doThrow
|
||||
import com.nhaarman.mockitokotlin2.mock
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.lang.RuntimeException
|
||||
|
||||
class NetworkTimelineRemoteMediatorTest {
|
||||
|
||||
private val accountManager: AccountManager = mock {
|
||||
on { activeAccount } doReturn AccountEntity(
|
||||
id = 1,
|
||||
domain = "mastodon.example",
|
||||
accessToken = "token",
|
||||
isActive = true
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ExperimentalPagingApi
|
||||
fun `should return error when network call returns error code`() {
|
||||
|
||||
val timelineViewModel: NetworkTimelineViewModel = mock {
|
||||
on { statusData } doReturn mutableListOf()
|
||||
onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody())
|
||||
}
|
||||
|
||||
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
|
||||
|
||||
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 timelineViewModel: NetworkTimelineViewModel = mock {
|
||||
on { statusData } doReturn mutableListOf()
|
||||
onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doThrow RuntimeException()
|
||||
}
|
||||
|
||||
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
|
||||
|
||||
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) }
|
||||
|
||||
assertTrue(result is RemoteMediator.MediatorResult.Error)
|
||||
assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is RuntimeException)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ExperimentalPagingApi
|
||||
fun `should not prepend statuses`() {
|
||||
val statuses: MutableList<StatusViewData> = mutableListOf(
|
||||
mockStatusViewData("3"),
|
||||
mockStatusViewData("2"),
|
||||
mockStatusViewData("1"),
|
||||
)
|
||||
|
||||
val timelineViewModel: NetworkTimelineViewModel = mock {
|
||||
on { statusData } doReturn statuses
|
||||
on { nextKey } doReturn "0"
|
||||
onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success(
|
||||
listOf(
|
||||
mockStatus("5"),
|
||||
mockStatus("4"),
|
||||
mockStatus("3")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
|
||||
|
||||
val state = state(
|
||||
listOf(
|
||||
PagingSource.LoadResult.Page(
|
||||
data = listOf(
|
||||
mockStatusViewData("3"),
|
||||
mockStatusViewData("2"),
|
||||
mockStatusViewData("1"),
|
||||
),
|
||||
prevKey = null,
|
||||
nextKey = "0"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
|
||||
|
||||
val newStatusData = mutableListOf(
|
||||
mockStatusViewData("5"),
|
||||
mockStatusViewData("4"),
|
||||
mockStatusViewData("3"),
|
||||
mockStatusViewData("2"),
|
||||
mockStatusViewData("1"),
|
||||
)
|
||||
|
||||
assertTrue(result is RemoteMediator.MediatorResult.Success)
|
||||
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
|
||||
assertEquals(newStatusData, statuses)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ExperimentalPagingApi
|
||||
fun `should refresh and insert placeholder`() {
|
||||
val statuses: MutableList<StatusViewData> = mutableListOf(
|
||||
mockStatusViewData("3"),
|
||||
mockStatusViewData("2"),
|
||||
mockStatusViewData("1"),
|
||||
)
|
||||
|
||||
val timelineViewModel: NetworkTimelineViewModel = mock {
|
||||
on { statusData } doReturn statuses
|
||||
on { nextKey } doReturn "0"
|
||||
onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success(
|
||||
listOf(
|
||||
mockStatus("10"),
|
||||
mockStatus("9"),
|
||||
mockStatus("7")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
|
||||
|
||||
val state = state(
|
||||
listOf(
|
||||
PagingSource.LoadResult.Page(
|
||||
data = listOf(
|
||||
mockStatusViewData("3"),
|
||||
mockStatusViewData("2"),
|
||||
mockStatusViewData("1"),
|
||||
),
|
||||
prevKey = null,
|
||||
nextKey = "0"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
|
||||
|
||||
val newStatusData = mutableListOf(
|
||||
mockStatusViewData("10"),
|
||||
mockStatusViewData("9"),
|
||||
mockStatusViewData("7"),
|
||||
StatusViewData.Placeholder("6", false),
|
||||
mockStatusViewData("3"),
|
||||
mockStatusViewData("2"),
|
||||
mockStatusViewData("1"),
|
||||
)
|
||||
|
||||
assertTrue(result is RemoteMediator.MediatorResult.Success)
|
||||
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
|
||||
assertEquals(newStatusData, statuses)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ExperimentalPagingApi
|
||||
fun `should refresh and not insert placeholders`() {
|
||||
val statuses: MutableList<StatusViewData> = mutableListOf(
|
||||
mockStatusViewData("8"),
|
||||
mockStatusViewData("7"),
|
||||
mockStatusViewData("5"),
|
||||
)
|
||||
|
||||
val timelineViewModel: NetworkTimelineViewModel = mock {
|
||||
on { statusData } doReturn statuses
|
||||
on { nextKey } doReturn "3"
|
||||
onBlocking { fetchStatusesForKind("3", null, 20) } doReturn Response.success(
|
||||
listOf(
|
||||
mockStatus("3"),
|
||||
mockStatus("2"),
|
||||
mockStatus("1")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
|
||||
|
||||
val state = state(
|
||||
listOf(
|
||||
PagingSource.LoadResult.Page(
|
||||
data = listOf(
|
||||
mockStatusViewData("8"),
|
||||
mockStatusViewData("7"),
|
||||
mockStatusViewData("5"),
|
||||
),
|
||||
prevKey = null,
|
||||
nextKey = "3"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
|
||||
|
||||
val newStatusData = mutableListOf(
|
||||
mockStatusViewData("8"),
|
||||
mockStatusViewData("7"),
|
||||
mockStatusViewData("5"),
|
||||
mockStatusViewData("3"),
|
||||
mockStatusViewData("2"),
|
||||
mockStatusViewData("1"),
|
||||
)
|
||||
|
||||
assertTrue(result is RemoteMediator.MediatorResult.Success)
|
||||
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
|
||||
assertEquals(newStatusData, statuses)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ExperimentalPagingApi
|
||||
fun `should append statuses`() {
|
||||
val statuses: MutableList<StatusViewData> = mutableListOf(
|
||||
mockStatusViewData("8"),
|
||||
mockStatusViewData("7"),
|
||||
mockStatusViewData("5"),
|
||||
)
|
||||
|
||||
val timelineViewModel: NetworkTimelineViewModel = mock {
|
||||
on { statusData } doReturn statuses
|
||||
on { nextKey } doReturn "3"
|
||||
onBlocking { fetchStatusesForKind("3", null, 20) } doReturn Response.success(
|
||||
listOf(
|
||||
mockStatus("3"),
|
||||
mockStatus("2"),
|
||||
mockStatus("1")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
|
||||
|
||||
val state = state(
|
||||
listOf(
|
||||
PagingSource.LoadResult.Page(
|
||||
data = listOf(
|
||||
mockStatusViewData("8"),
|
||||
mockStatusViewData("7"),
|
||||
mockStatusViewData("5"),
|
||||
),
|
||||
prevKey = null,
|
||||
nextKey = "3"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
|
||||
|
||||
val newStatusData = mutableListOf(
|
||||
mockStatusViewData("8"),
|
||||
mockStatusViewData("7"),
|
||||
mockStatusViewData("5"),
|
||||
mockStatusViewData("3"),
|
||||
mockStatusViewData("2"),
|
||||
mockStatusViewData("1"),
|
||||
)
|
||||
|
||||
assertTrue(result is RemoteMediator.MediatorResult.Success)
|
||||
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
|
||||
assertEquals(newStatusData, statuses)
|
||||
}
|
||||
|
||||
private fun state(pages: List<PagingSource.LoadResult.Page<String, StatusViewData>> = emptyList()) = PagingState(
|
||||
pages = pages,
|
||||
anchorPosition = null,
|
||||
config = PagingConfig(
|
||||
pageSize = 20
|
||||
),
|
||||
leadingPlaceholderCount = 0
|
||||
)
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
package com.keylesspalace.tusky.components.timeline
|
||||
|
||||
import android.text.SpannedString
|
||||
import com.google.gson.Gson
|
||||
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import java.util.ArrayList
|
||||
import java.util.Date
|
||||
|
||||
private val fixedDate = Date(1638889052000)
|
||||
|
||||
fun mockStatus(id: String = "100") = Status(
|
||||
id = id,
|
||||
url = "https://mastodon.example/@ConnyDuck/$id",
|
||||
account = Account(
|
||||
id = "1",
|
||||
localUsername = "connyduck",
|
||||
username = "connyduck@mastodon.example",
|
||||
displayName = "Conny Duck",
|
||||
note = SpannedString(""),
|
||||
url = "https://mastodon.example/@ConnyDuck",
|
||||
avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg",
|
||||
header = "https://mastodon.example/system/accounts/header/000/106/476/original/e590545d7eb4da39.jpg"
|
||||
),
|
||||
inReplyToId = null,
|
||||
inReplyToAccountId = null,
|
||||
reblog = null,
|
||||
content = SpannedString("Test"),
|
||||
createdAt = fixedDate,
|
||||
emojis = emptyList(),
|
||||
reblogsCount = 1,
|
||||
favouritesCount = 2,
|
||||
reblogged = false,
|
||||
favourited = true,
|
||||
bookmarked = true,
|
||||
sensitive = true,
|
||||
spoilerText = "",
|
||||
visibility = Status.Visibility.PUBLIC,
|
||||
attachments = ArrayList(),
|
||||
mentions = emptyList(),
|
||||
application = Status.Application("Tusky", "https://tusky.app"),
|
||||
pinned = false,
|
||||
muted = false,
|
||||
poll = null,
|
||||
card = null
|
||||
)
|
||||
|
||||
fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete(
|
||||
status = mockStatus(id),
|
||||
isExpanded = false,
|
||||
isShowingContent = false,
|
||||
isCollapsible = false,
|
||||
isCollapsed = true,
|
||||
)
|
||||
|
||||
fun mockStatusEntityWithAccount(
|
||||
id: String = "100",
|
||||
userId: Long = 1,
|
||||
expanded: Boolean = false
|
||||
): TimelineStatusWithAccount {
|
||||
val mockedStatus = mockStatus(id)
|
||||
val gson = Gson()
|
||||
|
||||
return TimelineStatusWithAccount().apply {
|
||||
status = mockedStatus.toEntity(
|
||||
timelineUserId = userId,
|
||||
gson = gson,
|
||||
expanded = expanded,
|
||||
contentShowing = false,
|
||||
contentCollapsed = true
|
||||
)
|
||||
account = mockedStatus.account.toEntity(
|
||||
accountId = userId,
|
||||
gson = gson
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,355 +0,0 @@
|
|||
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.any
|
||||
import org.mockito.ArgumentMatchers.anyInt
|
||||
import org.mockito.ArgumentMatchers.anyLong
|
||||
import org.mockito.Mock
|
||||
import org.mockito.MockitoAnnotations
|
||||
import org.robolectric.annotation.Config
|
||||
import retrofit2.Response
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@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
|
||||
)
|
||||
}
|
|
@ -1,792 +1,215 @@
|
|||
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 android.os.Looper
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.paging.AsyncPagingDataDiffer
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.recyclerview.widget.ListUpdateCallback
|
||||
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.appstore.EventHubImpl
|
||||
import com.keylesspalace.tusky.components.timeline.TimelinePagingAdapter.Companion.TimelineDifferCallback
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||
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.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.Converters
|
||||
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.clearInvocations
|
||||
import com.keylesspalace.tusky.network.TimelineCasesImpl
|
||||
import com.nhaarman.mockitokotlin2.doReturn
|
||||
import com.nhaarman.mockitokotlin2.eq
|
||||
import com.nhaarman.mockitokotlin2.isNull
|
||||
import com.nhaarman.mockitokotlin2.mock
|
||||
import com.nhaarman.mockitokotlin2.times
|
||||
import com.nhaarman.mockitokotlin2.verify
|
||||
import com.nhaarman.mockitokotlin2.whenever
|
||||
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.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.TestCoroutineDispatcher
|
||||
import kotlinx.coroutines.test.TestCoroutineScope
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import okhttp3.Headers
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.shadows.ShadowLog
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@Config(sdk = [29])
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
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
|
||||
|
||||
@get:Rule
|
||||
val instantRule = InstantTaskExecutorRule()
|
||||
|
||||
private val testDispatcher = TestCoroutineDispatcher()
|
||||
private val testScope = TestCoroutineScope(testDispatcher)
|
||||
|
||||
private val accountManager: AccountManager = mock {
|
||||
on { activeAccount } doReturn AccountEntity(
|
||||
id = 1,
|
||||
domain = "mastodon.example",
|
||||
accessToken = "token",
|
||||
isActive = true
|
||||
)
|
||||
}
|
||||
|
||||
private lateinit var db: AppDatabase
|
||||
|
||||
@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,
|
||||
)
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
accountManager = mock {
|
||||
on { activeAccount } doReturn account
|
||||
}
|
||||
sharedPreference = mock()
|
||||
viewModel = TimelineViewModel(
|
||||
timelineRepository,
|
||||
timelineCases,
|
||||
mastodonApi,
|
||||
eventHub,
|
||||
accountManager,
|
||||
sharedPreference,
|
||||
FilterModel()
|
||||
)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
|
||||
.addTypeConverter(Converters(Gson()))
|
||||
.setTransactionExecutor(Executors.newSingleThreadExecutor())
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
testDispatcher.cleanupTestCoroutines()
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadInitial, home, without cache, empty response`() {
|
||||
val initialResponse = listOf<Status>()
|
||||
setCachedResponse(initialResponse)
|
||||
@ExperimentalPagingApi
|
||||
fun shouldLoadNetworkTimeline() = runBlocking {
|
||||
|
||||
// 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(
|
||||
val api: MastodonApi = mock {
|
||||
on { publicTimeline(local = true, maxId = null, sinceId = null, limit = 30) } doReturn Single.just(
|
||||
Response.success(
|
||||
listOf(
|
||||
status
|
||||
mockStatus("6"),
|
||||
mockStatus("5"),
|
||||
mockStatus("4")
|
||||
),
|
||||
Headers.headersOf(
|
||||
"Link", "<https://mastodon.examples/api/v1/favourites?limit=30&max_id=1>; rel=\"next\", <https://mastodon.example/api/v1/favourites?limit=30&min_id=5>; rel=\"prev\""
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
on { publicTimeline(local = true, maxId = "1", sinceId = null, limit = 30) } doReturn Single.just(
|
||||
Response.success(emptyList())
|
||||
)
|
||||
|
||||
on { getFilters() } doReturn Single.just(emptyList())
|
||||
}
|
||||
|
||||
val viewModel = NetworkTimelineViewModel(
|
||||
TimelineCasesImpl(api, EventHubImpl),
|
||||
api,
|
||||
EventHubImpl,
|
||||
accountManager,
|
||||
mock(),
|
||||
FilterModel()
|
||||
)
|
||||
|
||||
val updates = viewModel.viewUpdates.test()
|
||||
viewModel.init(TimelineViewModel.Kind.PUBLIC_LOCAL, null, emptyList())
|
||||
|
||||
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)
|
||||
val differ = AsyncPagingDataDiffer(
|
||||
diffCallback = TimelineDifferCallback,
|
||||
updateCallback = NoopListCallback(),
|
||||
workerDispatcher = testDispatcher
|
||||
)
|
||||
|
||||
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()
|
||||
viewModel.statuses.take(2).collectLatest {
|
||||
testScope.launch {
|
||||
differ.submitData(it)
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
ownVotes = null
|
||||
)
|
||||
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()
|
||||
listOf(
|
||||
mockStatusViewData("6"),
|
||||
mockStatusViewData("5"),
|
||||
mockStatusViewData("4")
|
||||
),
|
||||
differ.snapshot().items
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertViewUpdated(updates: @NonNull TestObserver<Unit>) {
|
||||
assertTrue("There were view updates", updates.values().isNotEmpty())
|
||||
}
|
||||
// ToDo: Find out why Room & coroutines are not playing nice here
|
||||
// @Test
|
||||
@ExperimentalPagingApi
|
||||
fun shouldLoadCachedTimeline() = runBlocking {
|
||||
|
||||
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)
|
||||
val api: MastodonApi = mock {
|
||||
on { homeTimeline(limit = 30) } doReturn Single.just(
|
||||
Response.success(
|
||||
listOf(
|
||||
mockStatus("6"),
|
||||
mockStatus("5"),
|
||||
mockStatus("4")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.thenReturn(Single.just(initialResponse))
|
||||
}
|
||||
|
||||
private fun setInitialRefreshWithGaps(maxId: String?, statuses: List<TimelineStatus>) {
|
||||
whenever(
|
||||
timelineRepository.getStatuses(
|
||||
maxId,
|
||||
null,
|
||||
null,
|
||||
LOAD_AT_ONCE,
|
||||
TimelineRequestMode.NETWORK
|
||||
on { homeTimeline(maxId = "1", sinceId = null, limit = 30) } doReturn Single.just(
|
||||
Response.success(emptyList())
|
||||
)
|
||||
).thenReturn(Single.just(statuses))
|
||||
}
|
||||
|
||||
private fun List<Status>.toViewData(): List<StatusViewData> = map {
|
||||
it.toViewData(
|
||||
alwaysShowSensitiveMedia = false,
|
||||
alwaysOpenSpoiler = false
|
||||
on { getFilters() } doReturn Single.just(emptyList())
|
||||
}
|
||||
|
||||
val viewModel = CachedTimelineViewModel(
|
||||
TimelineCasesImpl(api, EventHubImpl),
|
||||
api,
|
||||
EventHubImpl,
|
||||
accountManager,
|
||||
mock(),
|
||||
FilterModel(),
|
||||
db,
|
||||
Gson()
|
||||
)
|
||||
|
||||
viewModel.init(TimelineViewModel.Kind.HOME, null, emptyList())
|
||||
|
||||
val differ = AsyncPagingDataDiffer(
|
||||
diffCallback = TimelineDifferCallback,
|
||||
updateCallback = NoopListCallback(),
|
||||
workerDispatcher = testDispatcher
|
||||
)
|
||||
|
||||
var x = 1
|
||||
viewModel.statuses.take(1000).collectLatest {
|
||||
testScope.launch {
|
||||
differ.submitData(it)
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
mockStatusViewData("6"),
|
||||
mockStatusViewData("5"),
|
||||
mockStatusViewData("4")
|
||||
),
|
||||
differ.snapshot().items
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<Status>.toEitherList() = map { Either.Right<Placeholder, Status>(it) }
|
||||
}
|
||||
|
||||
class NoopListCallback : ListUpdateCallback {
|
||||
override fun onChanged(position: Int, count: Int, payload: Any?) {}
|
||||
override fun onMoved(fromPosition: Int, toPosition: Int) {}
|
||||
override fun onInserted(position: Int, count: Int) {}
|
||||
override fun onRemoved(position: Int, count: Int) {}
|
||||
}
|
||||
|
|
331
app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt
Normal file
331
app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt
Normal file
|
@ -0,0 +1,331 @@
|
|||
package com.keylesspalace.tusky.db
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
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.appstore.CacheUpdater
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@Config(sdk = [28])
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TimelineDaoTest {
|
||||
private lateinit var timelineDao: TimelineDao
|
||||
private lateinit var db: AppDatabase
|
||||
|
||||
@Before
|
||||
fun createDb() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
|
||||
.addTypeConverter(Converters(Gson()))
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
timelineDao = db.timelineDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun closeDb() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertGetStatus() = runBlocking {
|
||||
val setOne = makeStatus(statusId = 3)
|
||||
val setTwo = makeStatus(statusId = 20, reblog = true)
|
||||
val ignoredOne = makeStatus(statusId = 1)
|
||||
val ignoredTwo = makeStatus(accountId = 2)
|
||||
|
||||
for ((status, author, reblogger) in listOf(setOne, setTwo, ignoredOne, ignoredTwo)) {
|
||||
timelineDao.insertAccount(author)
|
||||
reblogger?.let {
|
||||
timelineDao.insertAccount(it)
|
||||
}
|
||||
timelineDao.insertStatus(status)
|
||||
}
|
||||
|
||||
val pagingSource = timelineDao.getStatusesForAccount(setOne.first.timelineUserId)
|
||||
|
||||
val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 2, false))
|
||||
|
||||
val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data
|
||||
|
||||
assertEquals(2, loadedStatuses.size)
|
||||
assertStatuses(listOf(setTwo, setOne), loadedStatuses)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cleanup() = runBlocking {
|
||||
val now = System.currentTimeMillis()
|
||||
val oldDate = now - CacheUpdater.CLEANUP_INTERVAL - 20_000
|
||||
val oldThisAccount = makeStatus(
|
||||
statusId = 5,
|
||||
createdAt = oldDate
|
||||
)
|
||||
val oldAnotherAccount = makeStatus(
|
||||
statusId = 10,
|
||||
createdAt = oldDate,
|
||||
accountId = 2
|
||||
)
|
||||
val recentThisAccount = makeStatus(
|
||||
statusId = 30,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
val recentAnotherAccount = makeStatus(
|
||||
statusId = 60,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
accountId = 2
|
||||
)
|
||||
|
||||
for ((status, author, reblogAuthor) in listOf(oldThisAccount, oldAnotherAccount, recentThisAccount, recentAnotherAccount)) {
|
||||
timelineDao.insertAccount(author)
|
||||
reblogAuthor?.let {
|
||||
timelineDao.insertAccount(it)
|
||||
}
|
||||
timelineDao.insertStatus(status)
|
||||
}
|
||||
|
||||
timelineDao.cleanup(now - CacheUpdater.CLEANUP_INTERVAL)
|
||||
|
||||
val loadParams: PagingSource.LoadParams<Int> = PagingSource.LoadParams.Refresh(null, 100, false)
|
||||
|
||||
val loadedStatusAccount1 = (timelineDao.getStatusesForAccount(1).load(loadParams) as PagingSource.LoadResult.Page).data
|
||||
val loadedStatusAccount2 = (timelineDao.getStatusesForAccount(2).load(loadParams) as PagingSource.LoadResult.Page).data
|
||||
|
||||
assertStatuses(listOf(recentThisAccount), loadedStatusAccount1)
|
||||
assertStatuses(listOf(recentAnotherAccount), loadedStatusAccount2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overwriteDeletedStatus() = runBlocking {
|
||||
|
||||
val oldStatuses = listOf(
|
||||
makeStatus(statusId = 3),
|
||||
makeStatus(statusId = 2),
|
||||
makeStatus(statusId = 1)
|
||||
)
|
||||
|
||||
timelineDao.deleteRange(1, oldStatuses.last().first.serverId, oldStatuses.first().first.serverId)
|
||||
|
||||
for ((status, author, reblogAuthor) in oldStatuses) {
|
||||
timelineDao.insertAccount(author)
|
||||
reblogAuthor?.let {
|
||||
timelineDao.insertAccount(it)
|
||||
}
|
||||
timelineDao.insertStatus(status)
|
||||
}
|
||||
|
||||
// status 2 gets deleted, newly loaded status contain only 1 + 3
|
||||
val newStatuses = listOf(
|
||||
makeStatus(statusId = 3),
|
||||
makeStatus(statusId = 1)
|
||||
)
|
||||
|
||||
timelineDao.deleteRange(1, newStatuses.last().first.serverId, newStatuses.first().first.serverId)
|
||||
|
||||
for ((status, author, reblogAuthor) in newStatuses) {
|
||||
timelineDao.insertAccount(author)
|
||||
reblogAuthor?.let {
|
||||
timelineDao.insertAccount(it)
|
||||
}
|
||||
timelineDao.insertStatus(status)
|
||||
}
|
||||
|
||||
// make sure status 2 is no longer in db
|
||||
|
||||
val pagingSource = timelineDao.getStatusesForAccount(1)
|
||||
|
||||
val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 100, false))
|
||||
|
||||
val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data
|
||||
|
||||
assertStatuses(newStatuses, loadedStatuses)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAllForInstance() = runBlocking {
|
||||
|
||||
val statusWithRedDomain1 = makeStatus(
|
||||
statusId = 15,
|
||||
accountId = 1,
|
||||
domain = "mastodon.red",
|
||||
authorServerId = "1"
|
||||
)
|
||||
val statusWithRedDomain2 = makeStatus(
|
||||
statusId = 14,
|
||||
accountId = 1,
|
||||
domain = "mastodon.red",
|
||||
authorServerId = "2"
|
||||
)
|
||||
val statusWithRedDomainOtherAccount = makeStatus(
|
||||
statusId = 12,
|
||||
accountId = 2,
|
||||
domain = "mastodon.red",
|
||||
authorServerId = "2"
|
||||
)
|
||||
val statusWithBlueDomain = makeStatus(
|
||||
statusId = 10,
|
||||
accountId = 1,
|
||||
domain = "mastodon.blue",
|
||||
authorServerId = "4"
|
||||
)
|
||||
val statusWithBlueDomainOtherAccount = makeStatus(
|
||||
statusId = 10,
|
||||
accountId = 2,
|
||||
domain = "mastodon.blue",
|
||||
authorServerId = "5"
|
||||
)
|
||||
val statusWithGreenDomain = makeStatus(
|
||||
statusId = 8,
|
||||
accountId = 1,
|
||||
domain = "mastodon.green",
|
||||
authorServerId = "6"
|
||||
)
|
||||
|
||||
for ((status, author, reblogAuthor) in listOf(statusWithRedDomain1, statusWithRedDomain2, statusWithRedDomainOtherAccount, statusWithBlueDomain, statusWithBlueDomainOtherAccount, statusWithGreenDomain)) {
|
||||
timelineDao.insertAccount(author)
|
||||
reblogAuthor?.let {
|
||||
timelineDao.insertAccount(it)
|
||||
}
|
||||
timelineDao.insertStatus(status)
|
||||
}
|
||||
|
||||
timelineDao.deleteAllFromInstance(1, "mastodon.red")
|
||||
timelineDao.deleteAllFromInstance(1, "mastodon.blu") // shouldn't delete anything
|
||||
timelineDao.deleteAllFromInstance(1, "greenmastodon.green") // shouldn't delete anything
|
||||
|
||||
val loadParams: PagingSource.LoadParams<Int> = PagingSource.LoadParams.Refresh(null, 100, false)
|
||||
|
||||
val statusesAccount1 = (timelineDao.getStatusesForAccount(1).load(loadParams) as PagingSource.LoadResult.Page).data
|
||||
val statusesAccount2 = (timelineDao.getStatusesForAccount(2).load(loadParams) as PagingSource.LoadResult.Page).data
|
||||
|
||||
assertStatuses(listOf(statusWithBlueDomain, statusWithGreenDomain), statusesAccount1)
|
||||
assertStatuses(listOf(statusWithRedDomainOtherAccount, statusWithBlueDomainOtherAccount), statusesAccount2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return null as topId when db is empty`() = runBlocking {
|
||||
assertNull(timelineDao.getTopId(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return correct topId`() = runBlocking {
|
||||
|
||||
val status1 = makeStatus(
|
||||
statusId = 4,
|
||||
accountId = 1,
|
||||
domain = "mastodon.test",
|
||||
authorServerId = "1"
|
||||
)
|
||||
val status2 = makeStatus(
|
||||
statusId = 33,
|
||||
accountId = 1,
|
||||
domain = "mastodon.test",
|
||||
authorServerId = "2"
|
||||
)
|
||||
val status3 = makeStatus(
|
||||
statusId = 22,
|
||||
accountId = 1,
|
||||
domain = "mastodon.test",
|
||||
authorServerId = "2"
|
||||
)
|
||||
|
||||
for ((status, author, reblogAuthor) in listOf(status1, status2, status3)) {
|
||||
timelineDao.insertAccount(author)
|
||||
reblogAuthor?.let {
|
||||
timelineDao.insertAccount(it)
|
||||
}
|
||||
timelineDao.insertStatus(status)
|
||||
}
|
||||
|
||||
assertEquals("33", timelineDao.getTopId(1))
|
||||
}
|
||||
|
||||
private fun makeStatus(
|
||||
accountId: Long = 1,
|
||||
statusId: Long = 10,
|
||||
reblog: Boolean = false,
|
||||
createdAt: Long = statusId,
|
||||
authorServerId: String = "20",
|
||||
domain: String = "mastodon.example"
|
||||
): Triple<TimelineStatusEntity, TimelineAccountEntity, TimelineAccountEntity?> {
|
||||
val author = TimelineAccountEntity(
|
||||
authorServerId,
|
||||
accountId,
|
||||
"localUsername@$domain",
|
||||
"username@$domain",
|
||||
"displayName",
|
||||
"blah",
|
||||
"avatar",
|
||||
"[\"tusky\": \"http://tusky.cool/emoji.jpg\"]",
|
||||
false
|
||||
)
|
||||
|
||||
val reblogAuthor = if (reblog) {
|
||||
TimelineAccountEntity(
|
||||
"R$authorServerId",
|
||||
accountId,
|
||||
"RlocalUsername",
|
||||
"Rusername",
|
||||
"RdisplayName",
|
||||
"Rblah",
|
||||
"Ravatar",
|
||||
"[]",
|
||||
false
|
||||
)
|
||||
} else null
|
||||
|
||||
val even = accountId % 2 == 0L
|
||||
val status = TimelineStatusEntity(
|
||||
serverId = statusId.toString(),
|
||||
url = "https://$domain/whatever/$statusId",
|
||||
timelineUserId = accountId,
|
||||
authorServerId = authorServerId,
|
||||
inReplyToId = "inReplyToId$statusId",
|
||||
inReplyToAccountId = "inReplyToAccountId$statusId",
|
||||
content = "Content!$statusId",
|
||||
createdAt = createdAt,
|
||||
emojis = "emojis$statusId",
|
||||
reblogsCount = 1 * statusId.toInt(),
|
||||
favouritesCount = 2 * statusId.toInt(),
|
||||
reblogged = even,
|
||||
favourited = !even,
|
||||
bookmarked = false,
|
||||
sensitive = even,
|
||||
spoilerText = "spoier$statusId",
|
||||
visibility = Status.Visibility.PRIVATE,
|
||||
attachments = "attachments$accountId",
|
||||
mentions = "mentions$accountId",
|
||||
application = "application$accountId",
|
||||
reblogServerId = if (reblog) (statusId * 100).toString() else null,
|
||||
reblogAccountId = reblogAuthor?.serverId,
|
||||
poll = null,
|
||||
muted = false,
|
||||
expanded = false,
|
||||
contentCollapsed = false,
|
||||
contentShowing = true,
|
||||
pinned = false
|
||||
)
|
||||
return Triple(status, author, reblogAuthor)
|
||||
}
|
||||
|
||||
private fun assertStatuses(
|
||||
expected: List<Triple<TimelineStatusEntity, TimelineAccountEntity, TimelineAccountEntity?>>,
|
||||
provided: List<TimelineStatusWithAccount>
|
||||
) {
|
||||
for ((exp, prov) in expected.zip(provided)) {
|
||||
val (status, author, reblogger) = exp
|
||||
assertEquals(status, prov.status)
|
||||
assertEquals(author, prov.account)
|
||||
assertEquals(reblogger, prov.reblogAccount)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue