Refactor notifications to Kotlin & paging (#4026)

This refactors the NotificationsFragment and related classes to Kotlin &
paging.
While trying to preserve as much of the original behavior as possible,
this adds the following improvements as well:
- The "show notifications filter" preference was added again
- The "load more" button now has a background ripple effect when clicked
- The "legal" report category of Mastodon 4.2 is now supported in report
notifications
- Unknown notifications now display "unknown notification type" instead
of an empty line

Other code quality improvements:
- All views from xml layouts are now referenced via ViewBindings
- the classes responsible for showing system notifications were moved to
a new package `systemnotifications` while the classes from this
refactoring are in `notifications`
- the id of the local Tusky account is now called `tuskyAccountId` in
all places I could find

closes https://github.com/tuskyapp/Tusky/issues/3429

---------

Co-authored-by: Zongle Wang <wangzongler@gmail.com>
This commit is contained in:
Konrad Pozniak 2024-05-03 18:27:10 +02:00 committed by GitHub
commit b2c0b18c8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 6992 additions and 4654 deletions

View file

@ -11,8 +11,8 @@ import androidx.work.testing.WorkManagerTestInitHelper
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.TimelineAccount

View file

@ -24,12 +24,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.EmojisEntity
import com.keylesspalace.tusky.db.InstanceDao
import com.keylesspalace.tusky.db.InstanceInfoEntity
import com.keylesspalace.tusky.db.dao.InstanceDao
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.db.entity.EmojisEntity
import com.keylesspalace.tusky.db.entity.InstanceInfoEntity
import com.keylesspalace.tusky.di.NetworkModule
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Instance

View file

@ -0,0 +1,135 @@
package com.keylesspalace.tusky.components.notifications
import androidx.paging.PagingSource
import androidx.room.withTransaction
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.fakeAccount
import com.keylesspalace.tusky.components.timeline.fakeStatus
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.entity.NotificationDataEntity
import com.keylesspalace.tusky.db.entity.NotificationEntity
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Report
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import java.util.Date
import org.junit.Assert.assertEquals
fun fakeNotification(
type: Notification.Type = Notification.Type.FAVOURITE,
id: String = "1",
account: TimelineAccount = fakeAccount(id = id),
status: Status? = fakeStatus(id = id),
report: Report? = null
) = Notification(
type = type,
id = id,
account = account,
status = status,
report = report
)
fun fakeReport(
id: String = "1",
category: String = "spam",
statusIds: List<String>? = null,
createdAt: Date = Date(1712509983273),
targetAccount: TimelineAccount = fakeAccount()
) = Report(
id = id,
category = category,
statusIds = statusIds,
createdAt = createdAt,
targetAccount = targetAccount
)
fun Notification.toNotificationDataEntity(
tuskyAccountId: Long,
isStatusExpanded: Boolean = false,
isStatusContentShowing: Boolean = false
) = NotificationDataEntity(
tuskyAccountId = tuskyAccountId,
type = type,
id = id,
account = account.toEntity(tuskyAccountId),
status = status?.toEntity(
tuskyAccountId = tuskyAccountId,
expanded = isStatusExpanded,
contentShowing = isStatusContentShowing,
contentCollapsed = true
),
statusAccount = status?.account?.toEntity(tuskyAccountId),
report = report?.toEntity(tuskyAccountId),
reportTargetAccount = report?.targetAccount?.toEntity(tuskyAccountId)
)
fun Placeholder.toNotificationDataEntity(
tuskyAccountId: Long
) = NotificationDataEntity(
tuskyAccountId = tuskyAccountId,
type = null,
id = id,
account = null,
status = null,
statusAccount = null,
report = null,
reportTargetAccount = null
)
suspend fun AppDatabase.insert(notifications: List<Notification>, tuskyAccountId: Long = 1) = withTransaction {
notifications.forEach { notification ->
timelineAccountDao().insert(
notification.account.toEntity(tuskyAccountId)
)
notification.report?.let { report ->
timelineAccountDao().insert(
report.targetAccount.toEntity(
tuskyAccountId = tuskyAccountId,
)
)
notificationsDao().insertReport(report.toEntity(tuskyAccountId))
}
notification.status?.let { status ->
timelineAccountDao().insert(
status.account.toEntity(
tuskyAccountId = tuskyAccountId,
)
)
timelineStatusDao().insert(
status.toEntity(
tuskyAccountId = tuskyAccountId,
expanded = false,
contentShowing = false,
contentCollapsed = true
)
)
}
notificationsDao().insertNotification(
NotificationEntity(
tuskyAccountId = tuskyAccountId,
type = notification.type,
id = notification.id,
accountId = notification.account.id,
statusId = notification.status?.id,
reportId = notification.report?.id,
loading = false
)
)
}
}
suspend fun AppDatabase.assertNotifications(
expected: List<NotificationDataEntity>,
tuskyAccountId: Long = 1
) {
val pagingSource = notificationsDao().getNotifications(tuskyAccountId)
val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 100, false))
val loaded = (loadResult as PagingSource.LoadResult.Page).data
assertEquals(expected, loaded)
}

View file

@ -0,0 +1,544 @@
package com.keylesspalace.tusky.components.notifications
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.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.db.entity.NotificationDataEntity
import com.keylesspalace.tusky.di.NetworkModule
import java.io.IOException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.mock
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import retrofit2.HttpException
import retrofit2.Response
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
class NotificationsRemoteMediatorTest {
private val accountManager: AccountManager = mock {
on { activeAccount } doReturn AccountEntity(
id = 1,
domain = "mastodon.example",
accessToken = "token",
clientId = "id",
clientSecret = "secret",
isActive = true
)
}
private lateinit var db: AppDatabase
private val moshi = NetworkModule.providesMoshi()
@Before
@ExperimentalCoroutinesApi
fun setup() {
shadowOf(getMainLooper()).idle()
val context = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.addTypeConverter(Converters(moshi))
.build()
}
@After
@ExperimentalCoroutinesApi
fun tearDown() {
db.close()
}
@Test
@ExperimentalPagingApi
fun `should return error when network call returns error code`() = runTest {
val remoteMediator = NotificationsRemoteMediator(
accountManager = accountManager,
api = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody())
},
db = db,
excludes = emptySet()
)
val result = 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`() = runTest {
val remoteMediator = NotificationsRemoteMediator(
accountManager = accountManager,
api = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException()
},
db = db,
excludes = emptySet()
)
val result = 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 notifications`() = runTest {
val remoteMediator = NotificationsRemoteMediator(
accountManager = accountManager,
api = mock(),
db = db,
excludes = emptySet()
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = listOf(
fakeNotification(id = "3").toNotificationDataEntity(1)
),
prevKey = null,
nextKey = 1
)
)
)
val result = remoteMediator.load(LoadType.PREPEND, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
}
@Test
@ExperimentalPagingApi
fun `should refresh and insert placeholder when a whole page with no overlap to existing notifications is loaded`() = runTest {
val notificationsAlreadyInDb = listOf(
fakeNotification(id = "3"),
fakeNotification(id = "2"),
fakeNotification(id = "1")
)
db.insert(notificationsAlreadyInDb)
val remoteMediator = NotificationsRemoteMediator(
accountManager = accountManager,
api = mock {
onBlocking { notifications(limit = 3, excludes = emptySet()) } doReturn Response.success(
listOf(
fakeNotification(id = "8"),
fakeNotification(id = "7"),
fakeNotification(id = "5")
)
)
onBlocking { notifications(maxId = "3", limit = 3, excludes = emptySet()) } doReturn Response.success(
listOf(
fakeNotification(id = "3"),
fakeNotification(id = "2"),
fakeNotification(id = "1")
)
)
},
db = db,
excludes = emptySet()
)
val state = state(
pages = listOf(
PagingSource.LoadResult.Page(
data = notificationsAlreadyInDb.map { it.toNotificationDataEntity(1) },
prevKey = null,
nextKey = 0
)
),
pageSize = 3
)
val result = remoteMediator.load(LoadType.REFRESH, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertNotifications(
listOf(
fakeNotification(id = "8").toNotificationDataEntity(1),
fakeNotification(id = "7").toNotificationDataEntity(1),
Placeholder(id = "5", loading = false).toNotificationDataEntity(1),
fakeNotification(id = "3").toNotificationDataEntity(1),
fakeNotification(id = "2").toNotificationDataEntity(1),
fakeNotification(id = "1").toNotificationDataEntity(1)
)
)
}
@Test
@ExperimentalPagingApi
fun `should refresh and not insert placeholder when less than a whole page is loaded`() = runTest {
val notificationsAlreadyInDb = listOf(
fakeNotification(id = "3"),
fakeNotification(id = "2"),
fakeNotification(id = "1")
)
db.insert(notificationsAlreadyInDb)
val remoteMediator = NotificationsRemoteMediator(
accountManager = accountManager,
api = mock {
onBlocking { notifications(limit = 20, excludes = emptySet()) } doReturn Response.success(
listOf(
fakeNotification(id = "8"),
fakeNotification(id = "7"),
fakeNotification(id = "5")
)
)
onBlocking { notifications(maxId = "3", limit = 20, excludes = emptySet()) } doReturn Response.success(
listOf(
fakeNotification(id = "3"),
fakeNotification(id = "2"),
fakeNotification(id = "1")
)
)
},
db = db,
excludes = emptySet()
)
val state = state(
pages = listOf(
PagingSource.LoadResult.Page(
data = notificationsAlreadyInDb.map { it.toNotificationDataEntity(1) },
prevKey = null,
nextKey = 0
)
)
)
val result = remoteMediator.load(LoadType.REFRESH, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertNotifications(
listOf(
fakeNotification(id = "8").toNotificationDataEntity(1),
fakeNotification(id = "7").toNotificationDataEntity(1),
fakeNotification(id = "5").toNotificationDataEntity(1),
fakeNotification(id = "3").toNotificationDataEntity(1),
fakeNotification(id = "2").toNotificationDataEntity(1),
fakeNotification(id = "1").toNotificationDataEntity(1)
)
)
}
@Test
@ExperimentalPagingApi
fun `should refresh and not insert placeholders when there is overlap with existing notifications`() = runTest {
val notificationsAlreadyInDb = listOf(
fakeNotification(id = "3"),
fakeNotification(id = "2"),
fakeNotification(id = "1")
)
db.insert(notificationsAlreadyInDb)
val remoteMediator = NotificationsRemoteMediator(
accountManager = accountManager,
api = mock {
onBlocking { notifications(limit = 3, excludes = emptySet()) } doReturn Response.success(
listOf(
fakeNotification(id = "6"),
fakeNotification(id = "4"),
fakeNotification(id = "3")
)
)
onBlocking { notifications(maxId = "3", limit = 3, excludes = emptySet()) } doReturn Response.success(
listOf(
fakeNotification(id = "3"),
fakeNotification(id = "2"),
fakeNotification(id = "1")
)
)
},
db = db,
excludes = emptySet()
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = notificationsAlreadyInDb.map { it.toNotificationDataEntity(1) },
prevKey = null,
nextKey = 0
)
),
pageSize = 3
)
val result = remoteMediator.load(LoadType.REFRESH, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertNotifications(
listOf(
fakeNotification(id = "6").toNotificationDataEntity(1),
fakeNotification(id = "4").toNotificationDataEntity(1),
fakeNotification(id = "3").toNotificationDataEntity(1),
fakeNotification(id = "2").toNotificationDataEntity(1),
fakeNotification(id = "1").toNotificationDataEntity(1)
)
)
}
@Test
@ExperimentalPagingApi
fun `should not try to refresh already cached notifications when db is empty`() = runTest {
val remoteMediator = NotificationsRemoteMediator(
accountManager = accountManager,
api = mock {
onBlocking { notifications(limit = 20, excludes = emptySet()) } doReturn Response.success(
listOf(
fakeNotification(id = "5"),
fakeNotification(id = "4"),
fakeNotification(id = "3")
)
)
},
db = db,
excludes = emptySet()
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = emptyList(),
prevKey = null,
nextKey = 0
)
)
)
val result = remoteMediator.load(LoadType.REFRESH, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertNotifications(
listOf(
fakeNotification(id = "5").toNotificationDataEntity(1),
fakeNotification(id = "4").toNotificationDataEntity(1),
fakeNotification(id = "3").toNotificationDataEntity(1)
)
)
}
@Test
@ExperimentalPagingApi
fun `should remove deleted notification from db and keep state of statuses in the remaining ones`() = runTest {
val notificationsAlreadyInDb = listOf(
fakeNotification(id = "3"),
fakeNotification(id = "2"),
fakeNotification(id = "1")
)
db.insert(notificationsAlreadyInDb)
db.timelineStatusDao().setExpanded(1, "3", true)
db.timelineStatusDao().setExpanded(1, "2", true)
db.timelineStatusDao().setContentCollapsed(1, "1", false)
val remoteMediator = NotificationsRemoteMediator(
accountManager = accountManager,
api = mock {
onBlocking { notifications(limit = 20, excludes = emptySet()) } doReturn Response.success(emptyList())
onBlocking { notifications(maxId = "3", limit = 20, excludes = emptySet()) } doReturn Response.success(
listOf(
fakeNotification(id = "3"),
fakeNotification(id = "1")
)
)
},
db = db,
excludes = emptySet()
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = listOf(
fakeNotification(id = "3").toNotificationDataEntity(1, isStatusExpanded = true),
fakeNotification(id = "2").toNotificationDataEntity(1, isStatusExpanded = true),
fakeNotification(id = "1").toNotificationDataEntity(1, isStatusContentShowing = true)
),
prevKey = null,
nextKey = 0
)
)
)
val result = remoteMediator.load(LoadType.REFRESH, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertNotifications(
listOf(
fakeNotification(id = "3").toNotificationDataEntity(1, isStatusExpanded = true),
fakeNotification(id = "1").toNotificationDataEntity(1, isStatusContentShowing = true)
)
)
}
@Test
@ExperimentalPagingApi
fun `should not remove placeholder in timeline`() = runTest {
val notificationsAlreadyInDb = listOf(
fakeNotification(id = "8"),
fakeNotification(id = "7"),
fakeNotification(id = "1")
)
db.insert(notificationsAlreadyInDb)
val placeholder = Placeholder(id = "6", loading = false).toNotificationEntity(1)
db.notificationsDao().insertNotification(placeholder)
val remoteMediator = NotificationsRemoteMediator(
accountManager = accountManager,
api = mock {
onBlocking { notifications(sinceId = "6", limit = 20, excludes = emptySet()) } doReturn Response.success(
listOf(
fakeNotification(id = "9"),
fakeNotification(id = "8"),
fakeNotification(id = "7")
)
)
onBlocking { notifications(maxId = "8", sinceId = "6", limit = 20, excludes = emptySet()) } doReturn Response.success(
listOf(
fakeNotification(id = "8"),
fakeNotification(id = "7")
)
)
},
db = db,
excludes = emptySet()
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = notificationsAlreadyInDb.map { it.toNotificationDataEntity(1) },
prevKey = null,
nextKey = 0
)
)
)
val result = remoteMediator.load(LoadType.REFRESH, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertFalse((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertNotifications(
listOf(
fakeNotification(id = "9").toNotificationDataEntity(1),
fakeNotification(id = "8").toNotificationDataEntity(1),
fakeNotification(id = "7").toNotificationDataEntity(1),
Placeholder(id = "6", loading = false).toNotificationDataEntity(1),
fakeNotification(id = "1").toNotificationDataEntity(1)
)
)
}
@Test
@ExperimentalPagingApi
fun `should append notifications`() = runTest {
val notificationsAlreadyInDb = listOf(
fakeNotification(id = "8"),
fakeNotification(id = "7"),
fakeNotification(id = "5")
)
db.insert(notificationsAlreadyInDb)
val remoteMediator = NotificationsRemoteMediator(
accountManager = accountManager,
api = mock {
onBlocking { notifications(maxId = "5", limit = 20, excludes = emptySet()) } doReturn Response.success(
listOf(
fakeNotification(id = "3"),
fakeNotification(id = "2"),
fakeNotification(id = "1")
)
)
},
db = db,
excludes = emptySet()
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = notificationsAlreadyInDb.map { it.toNotificationDataEntity(1) },
prevKey = null,
nextKey = 0
)
)
)
val result = remoteMediator.load(LoadType.APPEND, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertNotifications(
listOf(
fakeNotification(id = "8").toNotificationDataEntity(1),
fakeNotification(id = "7").toNotificationDataEntity(1),
fakeNotification(id = "5").toNotificationDataEntity(1),
fakeNotification(id = "3").toNotificationDataEntity(1),
fakeNotification(id = "2").toNotificationDataEntity(1),
fakeNotification(id = "1").toNotificationDataEntity(1)
)
)
}
private fun state(
pages: List<PagingSource.LoadResult.Page<Int, NotificationDataEntity>> = emptyList(),
pageSize: Int = 20
) = PagingState(
pages = pages,
anchorPosition = null,
config = PagingConfig(
pageSize = pageSize
),
leadingPlaceholderCount = 0
)
}

View file

@ -11,15 +11,15 @@ import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
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.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.db.entity.HomeTimelineData
import com.keylesspalace.tusky.di.NetworkModule
import java.io.IOException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
@ -75,17 +75,16 @@ class CachedTimelineRemoteMediatorTest {
@Test
@ExperimentalPagingApi
fun `should return error when network call returns error code`() {
fun `should return error when network call returns error code`() = runTest {
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock {
onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody())
},
db = db,
moshi = moshi
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) }
val result = remoteMediator.load(LoadType.REFRESH, state())
assertTrue(result is RemoteMediator.MediatorResult.Error)
assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is HttpException)
@ -94,17 +93,16 @@ class CachedTimelineRemoteMediatorTest {
@Test
@ExperimentalPagingApi
fun `should return error when network call fails`() {
fun `should return error when network call fails`() = runTest {
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock {
onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException()
},
db = db,
moshi = moshi
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) }
val result = remoteMediator.load(LoadType.REFRESH, state())
assertTrue(result is RemoteMediator.MediatorResult.Error)
assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is IOException)
@ -112,19 +110,18 @@ class CachedTimelineRemoteMediatorTest {
@Test
@ExperimentalPagingApi
fun `should not prepend statuses`() {
fun `should not prepend statuses`() = runTest {
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock(),
db = db,
moshi = moshi
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = listOf(
mockStatusEntityWithAccount("3")
fakeHomeTimelineData("3")
),
prevKey = null,
nextKey = 1
@ -132,7 +129,7 @@ class CachedTimelineRemoteMediatorTest {
)
)
val result = runBlocking { remoteMediator.load(LoadType.PREPEND, state) }
val result = remoteMediator.load(LoadType.PREPEND, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
@ -140,11 +137,11 @@ class CachedTimelineRemoteMediatorTest {
@Test
@ExperimentalPagingApi
fun `should refresh and insert placeholder when a whole page with no overlap to existing statuses is loaded`() {
fun `should refresh and insert placeholder when a whole page with no overlap to existing statuses is loaded`() = runTest {
val statusesAlreadyInDb = listOf(
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1")
fakeHomeTimelineData("3"),
fakeHomeTimelineData("2"),
fakeHomeTimelineData("1")
)
db.insert(statusesAlreadyInDb)
@ -154,21 +151,20 @@ class CachedTimelineRemoteMediatorTest {
api = mock {
onBlocking { homeTimeline(limit = 3) } doReturn Response.success(
listOf(
mockStatus("8"),
mockStatus("7"),
mockStatus("5")
fakeStatus("8"),
fakeStatus("7"),
fakeStatus("5")
)
)
onBlocking { homeTimeline(maxId = "3", limit = 3) } doReturn Response.success(
listOf(
mockStatus("3"),
mockStatus("2"),
mockStatus("1")
fakeStatus("3"),
fakeStatus("2"),
fakeStatus("1")
)
)
},
db = db,
moshi = moshi
)
val state = state(
@ -182,32 +178,30 @@ class CachedTimelineRemoteMediatorTest {
pageSize = 3
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
val result = remoteMediator.load(LoadType.REFRESH, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
db.assertTimeline(
listOf(
mockStatusEntityWithAccount("8"),
mockStatusEntityWithAccount("7"),
TimelineStatusWithAccount(
status = Placeholder("5", loading = false).toEntity(1)
),
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1")
fakeHomeTimelineData("8"),
fakeHomeTimelineData("7"),
fakePlaceholderHomeTimelineData("5"),
fakeHomeTimelineData("3"),
fakeHomeTimelineData("2"),
fakeHomeTimelineData("1")
)
)
}
@Test
@ExperimentalPagingApi
fun `should refresh and not insert placeholder when less than a whole page is loaded`() {
fun `should refresh and not insert placeholder when less than a whole page is loaded`() = runTest {
val statusesAlreadyInDb = listOf(
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1")
fakeHomeTimelineData("3"),
fakeHomeTimelineData("2"),
fakeHomeTimelineData("1")
)
db.insert(statusesAlreadyInDb)
@ -217,21 +211,20 @@ class CachedTimelineRemoteMediatorTest {
api = mock {
onBlocking { homeTimeline(limit = 20) } doReturn Response.success(
listOf(
mockStatus("8"),
mockStatus("7"),
mockStatus("5")
fakeStatus("8"),
fakeStatus("7"),
fakeStatus("5")
)
)
onBlocking { homeTimeline(maxId = "3", limit = 20) } doReturn Response.success(
listOf(
mockStatus("3"),
mockStatus("2"),
mockStatus("1")
fakeStatus("3"),
fakeStatus("2"),
fakeStatus("1")
)
)
},
db = db,
moshi = moshi
)
val state = state(
@ -244,30 +237,30 @@ class CachedTimelineRemoteMediatorTest {
)
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
val result = remoteMediator.load(LoadType.REFRESH, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
db.assertTimeline(
listOf(
mockStatusEntityWithAccount("8"),
mockStatusEntityWithAccount("7"),
mockStatusEntityWithAccount("5"),
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1")
fakeHomeTimelineData("8"),
fakeHomeTimelineData("7"),
fakeHomeTimelineData("5"),
fakeHomeTimelineData("3"),
fakeHomeTimelineData("2"),
fakeHomeTimelineData("1")
)
)
}
@Test
@ExperimentalPagingApi
fun `should refresh and not insert placeholders when there is overlap with existing statuses`() {
fun `should refresh and not insert placeholders when there is overlap with existing statuses`() = runTest {
val statusesAlreadyInDb = listOf(
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1")
fakeHomeTimelineData("3"),
fakeHomeTimelineData("2"),
fakeHomeTimelineData("1")
)
db.insert(statusesAlreadyInDb)
@ -277,21 +270,20 @@ class CachedTimelineRemoteMediatorTest {
api = mock {
onBlocking { homeTimeline(limit = 3) } doReturn Response.success(
listOf(
mockStatus("6"),
mockStatus("4"),
mockStatus("3")
fakeStatus("6"),
fakeStatus("4"),
fakeStatus("3")
)
)
onBlocking { homeTimeline(maxId = "3", limit = 3) } doReturn Response.success(
listOf(
mockStatus("3"),
mockStatus("2"),
mockStatus("1")
fakeStatus("3"),
fakeStatus("2"),
fakeStatus("1")
)
)
},
db = db,
moshi = moshi
)
val state = state(
@ -305,38 +297,37 @@ class CachedTimelineRemoteMediatorTest {
pageSize = 3
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
val result = remoteMediator.load(LoadType.REFRESH, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
db.assertTimeline(
listOf(
mockStatusEntityWithAccount("6"),
mockStatusEntityWithAccount("4"),
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1")
fakeHomeTimelineData("6"),
fakeHomeTimelineData("4"),
fakeHomeTimelineData("3"),
fakeHomeTimelineData("2"),
fakeHomeTimelineData("1")
)
)
}
@Test
@ExperimentalPagingApi
fun `should not try to refresh already cached statuses when db is empty`() {
fun `should not try to refresh already cached statuses when db is empty`() = runTest {
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock {
onBlocking { homeTimeline(limit = 20) } doReturn Response.success(
listOf(
mockStatus("5"),
mockStatus("4"),
mockStatus("3")
fakeStatus("5"),
fakeStatus("4"),
fakeStatus("3")
)
)
},
db = db,
moshi = moshi
)
val state = state(
@ -349,27 +340,27 @@ class CachedTimelineRemoteMediatorTest {
)
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
val result = remoteMediator.load(LoadType.REFRESH, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
db.assertTimeline(
listOf(
mockStatusEntityWithAccount("5"),
mockStatusEntityWithAccount("4"),
mockStatusEntityWithAccount("3")
fakeHomeTimelineData("5"),
fakeHomeTimelineData("4"),
fakeHomeTimelineData("3")
)
)
}
@Test
@ExperimentalPagingApi
fun `should remove deleted status from db and keep state of other cached statuses`() {
fun `should remove deleted status from db and keep state of other cached statuses`() = runTest {
val statusesAlreadyInDb = listOf(
mockStatusEntityWithAccount("3", expanded = true),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1", expanded = false)
fakeHomeTimelineData("3", expanded = true),
fakeHomeTimelineData("2"),
fakeHomeTimelineData("1", expanded = false)
)
db.insert(statusesAlreadyInDb)
@ -381,13 +372,12 @@ class CachedTimelineRemoteMediatorTest {
onBlocking { homeTimeline(maxId = "3", limit = 20) } doReturn Response.success(
listOf(
mockStatus("3"),
mockStatus("1")
fakeStatus("3"),
fakeStatus("1")
)
)
},
db = db,
moshi = moshi
)
val state = state(
@ -400,27 +390,27 @@ class CachedTimelineRemoteMediatorTest {
)
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
val result = remoteMediator.load(LoadType.REFRESH, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
db.assertTimeline(
listOf(
mockStatusEntityWithAccount("3", expanded = true),
mockStatusEntityWithAccount("1", expanded = false)
fakeHomeTimelineData("3", expanded = true),
fakeHomeTimelineData("1", expanded = false)
)
)
}
@Test
@ExperimentalPagingApi
fun `should not remove placeholder in timeline`() {
fun `should not remove placeholder in timeline`() = runTest {
val statusesAlreadyInDb = listOf(
mockStatusEntityWithAccount("8"),
mockStatusEntityWithAccount("7"),
mockPlaceholderEntityWithAccount("6"),
mockStatusEntityWithAccount("1")
fakeHomeTimelineData("8"),
fakeHomeTimelineData("7"),
fakePlaceholderHomeTimelineData("6"),
fakeHomeTimelineData("1")
)
db.insert(statusesAlreadyInDb)
@ -430,20 +420,19 @@ class CachedTimelineRemoteMediatorTest {
api = mock {
onBlocking { homeTimeline(sinceId = "6", limit = 20) } doReturn Response.success(
listOf(
mockStatus("9"),
mockStatus("8"),
mockStatus("7")
fakeStatus("9"),
fakeStatus("8"),
fakeStatus("7")
)
)
onBlocking { homeTimeline(maxId = "8", sinceId = "6", limit = 20) } doReturn Response.success(
listOf(
mockStatus("8"),
mockStatus("7")
fakeStatus("8"),
fakeStatus("7")
)
)
},
db = db,
moshi = moshi
)
val state = state(
@ -456,29 +445,29 @@ class CachedTimelineRemoteMediatorTest {
)
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
val result = remoteMediator.load(LoadType.REFRESH, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertFalse((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
db.assertTimeline(
listOf(
mockStatusEntityWithAccount("9"),
mockStatusEntityWithAccount("8"),
mockStatusEntityWithAccount("7"),
mockPlaceholderEntityWithAccount("6"),
mockStatusEntityWithAccount("1")
fakeHomeTimelineData("9"),
fakeHomeTimelineData("8"),
fakeHomeTimelineData("7"),
fakePlaceholderHomeTimelineData("6"),
fakeHomeTimelineData("1")
)
)
}
@Test
@ExperimentalPagingApi
fun `should append statuses`() {
fun `should append statuses`() = runTest {
val statusesAlreadyInDb = listOf(
mockStatusEntityWithAccount("8"),
mockStatusEntityWithAccount("7"),
mockStatusEntityWithAccount("5")
fakeHomeTimelineData("8"),
fakeHomeTimelineData("7"),
fakeHomeTimelineData("5")
)
db.insert(statusesAlreadyInDb)
@ -488,14 +477,13 @@ class CachedTimelineRemoteMediatorTest {
api = mock {
onBlocking { homeTimeline(maxId = "5", limit = 20) } doReturn Response.success(
listOf(
mockStatus("3"),
mockStatus("2"),
mockStatus("1")
fakeStatus("3"),
fakeStatus("2"),
fakeStatus("1")
)
)
},
db = db,
moshi = moshi
)
val state = state(
@ -508,24 +496,24 @@ class CachedTimelineRemoteMediatorTest {
)
)
val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
val result = remoteMediator.load(LoadType.APPEND, state)
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
db.assertTimeline(
listOf(
mockStatusEntityWithAccount("8"),
mockStatusEntityWithAccount("7"),
mockStatusEntityWithAccount("5"),
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1")
fakeHomeTimelineData("8"),
fakeHomeTimelineData("7"),
fakeHomeTimelineData("5"),
fakeHomeTimelineData("3"),
fakeHomeTimelineData("2"),
fakeHomeTimelineData("1")
)
)
}
private fun state(
pages: List<PagingSource.LoadResult.Page<Int, TimelineStatusWithAccount>> = emptyList(),
pages: List<PagingSource.LoadResult.Page<Int, HomeTimelineData>> = emptyList(),
pageSize: Int = 20
) = PagingState(
pages = pages,
@ -535,41 +523,4 @@ class CachedTimelineRemoteMediatorTest {
),
leadingPlaceholderCount = 0
)
private fun AppDatabase.insert(statuses: List<TimelineStatusWithAccount>) {
runBlocking {
statuses.forEach { statusWithAccount ->
statusWithAccount.account?.let { account ->
timelineDao().insertAccount(account)
}
statusWithAccount.reblogAccount?.let { account ->
timelineDao().insertAccount(account)
}
timelineDao().insertStatus(statusWithAccount.status)
}
}
}
private fun AppDatabase.assertStatuses(
expected: List<TimelineStatusWithAccount>,
forAccount: Long = 1
) {
val pagingSource = timelineDao().getStatuses(forAccount)
val loadResult = runBlocking {
pagingSource.load(PagingSource.LoadParams.Refresh(null, 100, false))
}
val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data
assertEquals(expected.size, loadedStatuses.size)
for ((exp, prov) in expected.zip(loadedStatuses)) {
assertEquals(exp.status, prov.status)
if (!exp.status.isPlaceholder) {
assertEquals(exp.account, prov.account)
assertEquals(exp.reblogAccount, prov.reblogAccount)
}
}
}
}

View file

@ -16,7 +16,7 @@ import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class NetworkTimelinePagingSourceTest {
private val status = mockStatusViewData()
private val status = fakeStatusViewData()
private val timelineViewModel: NetworkTimelineViewModel = mock {
on { statusData } doReturn mutableListOf(status)

View file

@ -10,8 +10,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineRemoteMediator
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.db.entity.AccountEntity
import com.keylesspalace.tusky.viewdata.StatusViewData
import java.io.IOException
import kotlinx.coroutines.runBlocking
@ -88,9 +88,9 @@ class NetworkTimelineRemoteMediatorTest {
on { nextKey } doReturn null
onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success(
listOf(
mockStatus("7"),
mockStatus("6"),
mockStatus("5")
fakeStatus("7"),
fakeStatus("6"),
fakeStatus("5")
),
Headers.headersOf(
"Link",
@ -114,9 +114,9 @@ class NetworkTimelineRemoteMediatorTest {
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
val newStatusData = mutableListOf(
mockStatusViewData("7"),
mockStatusViewData("6"),
mockStatusViewData("5")
fakeStatusViewData("7"),
fakeStatusViewData("6"),
fakeStatusViewData("5")
)
verify(timelineViewModel).nextKey = "4"
@ -129,9 +129,9 @@ class NetworkTimelineRemoteMediatorTest {
@ExperimentalPagingApi
fun `should not prepend statuses`() {
val statuses: MutableList<StatusViewData> = mutableListOf(
mockStatusViewData("3"),
mockStatusViewData("2"),
mockStatusViewData("1")
fakeStatusViewData("3"),
fakeStatusViewData("2"),
fakeStatusViewData("1")
)
val timelineViewModel: NetworkTimelineViewModel = mock {
@ -139,9 +139,9 @@ class NetworkTimelineRemoteMediatorTest {
on { nextKey } doReturn "0"
onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success(
listOf(
mockStatus("5"),
mockStatus("4"),
mockStatus("3")
fakeStatus("5"),
fakeStatus("4"),
fakeStatus("3")
)
)
}
@ -152,9 +152,9 @@ class NetworkTimelineRemoteMediatorTest {
listOf(
PagingSource.LoadResult.Page(
data = listOf(
mockStatusViewData("3"),
mockStatusViewData("2"),
mockStatusViewData("1")
fakeStatusViewData("3"),
fakeStatusViewData("2"),
fakeStatusViewData("1")
),
prevKey = null,
nextKey = "0"
@ -165,11 +165,11 @@ class NetworkTimelineRemoteMediatorTest {
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
val newStatusData = mutableListOf(
mockStatusViewData("5"),
mockStatusViewData("4"),
mockStatusViewData("3"),
mockStatusViewData("2"),
mockStatusViewData("1")
fakeStatusViewData("5"),
fakeStatusViewData("4"),
fakeStatusViewData("3"),
fakeStatusViewData("2"),
fakeStatusViewData("1")
)
assertTrue(result is RemoteMediator.MediatorResult.Success)
@ -181,9 +181,9 @@ class NetworkTimelineRemoteMediatorTest {
@ExperimentalPagingApi
fun `should refresh and insert placeholder`() {
val statuses: MutableList<StatusViewData> = mutableListOf(
mockStatusViewData("3"),
mockStatusViewData("2"),
mockStatusViewData("1")
fakeStatusViewData("3"),
fakeStatusViewData("2"),
fakeStatusViewData("1")
)
val timelineViewModel: NetworkTimelineViewModel = mock {
@ -191,9 +191,9 @@ class NetworkTimelineRemoteMediatorTest {
on { nextKey } doReturn "0"
onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success(
listOf(
mockStatus("10"),
mockStatus("9"),
mockStatus("7")
fakeStatus("10"),
fakeStatus("9"),
fakeStatus("7")
)
)
}
@ -204,9 +204,9 @@ class NetworkTimelineRemoteMediatorTest {
listOf(
PagingSource.LoadResult.Page(
data = listOf(
mockStatusViewData("3"),
mockStatusViewData("2"),
mockStatusViewData("1")
fakeStatusViewData("3"),
fakeStatusViewData("2"),
fakeStatusViewData("1")
),
prevKey = null,
nextKey = "0"
@ -217,12 +217,12 @@ class NetworkTimelineRemoteMediatorTest {
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
val newStatusData = mutableListOf(
mockStatusViewData("10"),
mockStatusViewData("9"),
fakeStatusViewData("10"),
fakeStatusViewData("9"),
StatusViewData.Placeholder("7", false),
mockStatusViewData("3"),
mockStatusViewData("2"),
mockStatusViewData("1")
fakeStatusViewData("3"),
fakeStatusViewData("2"),
fakeStatusViewData("1")
)
assertTrue(result is RemoteMediator.MediatorResult.Success)
@ -234,9 +234,9 @@ class NetworkTimelineRemoteMediatorTest {
@ExperimentalPagingApi
fun `should refresh and not insert placeholders`() {
val statuses: MutableList<StatusViewData> = mutableListOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5")
)
val timelineViewModel: NetworkTimelineViewModel = mock {
@ -244,9 +244,9 @@ class NetworkTimelineRemoteMediatorTest {
on { nextKey } doReturn "3"
onBlocking { fetchStatusesForKind("3", null, 20) } doReturn Response.success(
listOf(
mockStatus("3"),
mockStatus("2"),
mockStatus("1")
fakeStatus("3"),
fakeStatus("2"),
fakeStatus("1")
)
)
}
@ -257,9 +257,9 @@ class NetworkTimelineRemoteMediatorTest {
listOf(
PagingSource.LoadResult.Page(
data = listOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5")
),
prevKey = null,
nextKey = "3"
@ -270,12 +270,12 @@ class NetworkTimelineRemoteMediatorTest {
val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
val newStatusData = mutableListOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5"),
mockStatusViewData("3"),
mockStatusViewData("2"),
mockStatusViewData("1")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5"),
fakeStatusViewData("3"),
fakeStatusViewData("2"),
fakeStatusViewData("1")
)
assertTrue(result is RemoteMediator.MediatorResult.Success)
@ -287,9 +287,9 @@ class NetworkTimelineRemoteMediatorTest {
@ExperimentalPagingApi
fun `should append statuses`() {
val statuses: MutableList<StatusViewData> = mutableListOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5")
)
val timelineViewModel: NetworkTimelineViewModel = mock {
@ -297,9 +297,9 @@ class NetworkTimelineRemoteMediatorTest {
on { nextKey } doReturn "3"
onBlocking { fetchStatusesForKind("3", null, 20) } doReturn Response.success(
listOf(
mockStatus("3"),
mockStatus("2"),
mockStatus("1")
fakeStatus("3"),
fakeStatus("2"),
fakeStatus("1")
),
Headers.headersOf(
"Link",
@ -314,9 +314,9 @@ class NetworkTimelineRemoteMediatorTest {
listOf(
PagingSource.LoadResult.Page(
data = listOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5")
),
prevKey = null,
nextKey = "3"
@ -327,12 +327,12 @@ class NetworkTimelineRemoteMediatorTest {
val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
val newStatusData = mutableListOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5"),
mockStatusViewData("3"),
mockStatusViewData("2"),
mockStatusViewData("1")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5"),
fakeStatusViewData("3"),
fakeStatusViewData("2"),
fakeStatusViewData("1")
)
verify(timelineViewModel).nextKey = "0"
assertTrue(result is RemoteMediator.MediatorResult.Success)
@ -344,9 +344,9 @@ class NetworkTimelineRemoteMediatorTest {
@ExperimentalPagingApi
fun `should not append statuses when pagination end has been reached`() {
val statuses: MutableList<StatusViewData> = mutableListOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5")
)
val timelineViewModel: NetworkTimelineViewModel = mock {
@ -360,9 +360,9 @@ class NetworkTimelineRemoteMediatorTest {
listOf(
PagingSource.LoadResult.Page(
data = listOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5")
),
prevKey = null,
nextKey = null
@ -373,9 +373,9 @@ class NetworkTimelineRemoteMediatorTest {
val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
val newStatusData = mutableListOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5")
)
assertTrue(result is RemoteMediator.MediatorResult.Success)
@ -387,9 +387,9 @@ class NetworkTimelineRemoteMediatorTest {
@ExperimentalPagingApi
fun `should not append duplicates for trending statuses`() {
val statuses: MutableList<StatusViewData> = mutableListOf(
mockStatusViewData("5"),
mockStatusViewData("4"),
mockStatusViewData("3")
fakeStatusViewData("5"),
fakeStatusViewData("4"),
fakeStatusViewData("3")
)
val timelineViewModel: NetworkTimelineViewModel = mock {
@ -398,9 +398,9 @@ class NetworkTimelineRemoteMediatorTest {
on { kind } doReturn TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES
onBlocking { fetchStatusesForKind("3", null, 20) } doReturn Response.success(
listOf(
mockStatus("3"),
mockStatus("2"),
mockStatus("1")
fakeStatus("3"),
fakeStatus("2"),
fakeStatus("1")
),
Headers.headersOf(
"Link",
@ -424,11 +424,11 @@ class NetworkTimelineRemoteMediatorTest {
val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
val newStatusData = mutableListOf(
mockStatusViewData("5"),
mockStatusViewData("4"),
mockStatusViewData("3"),
mockStatusViewData("2"),
mockStatusViewData("1")
fakeStatusViewData("5"),
fakeStatusViewData("4"),
fakeStatusViewData("3"),
fakeStatusViewData("2"),
fakeStatusViewData("1")
)
verify(timelineViewModel).nextKey = "5"
assertTrue(result is RemoteMediator.MediatorResult.Success)

View file

@ -1,118 +0,0 @@
package com.keylesspalace.tusky.components.timeline
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.di.NetworkModule
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date
private val fixedDate = Date(1638889052000)
fun mockStatus(
id: String = "100",
inReplyToId: String? = null,
inReplyToAccountId: String? = null,
spoilerText: String = "",
reblogged: Boolean = false,
favourited: Boolean = true,
bookmarked: Boolean = true
) = Status(
id = id,
url = "https://mastodon.example/@ConnyDuck/$id",
account = TimelineAccount(
id = "1",
localUsername = "connyduck",
username = "connyduck@mastodon.example",
displayName = "Conny Duck",
note = "This is their bio",
url = "https://mastodon.example/@ConnyDuck",
avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg"
),
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
reblog = null,
content = "Test",
createdAt = fixedDate,
editedAt = null,
emojis = emptyList(),
reblogsCount = 1,
favouritesCount = 2,
repliesCount = 3,
reblogged = reblogged,
favourited = favourited,
bookmarked = bookmarked,
sensitive = true,
spoilerText = spoilerText,
visibility = Status.Visibility.PUBLIC,
attachments = ArrayList(),
mentions = emptyList(),
tags = emptyList(),
application = Status.Application("Tusky", "https://tusky.app"),
pinned = false,
muted = false,
poll = null,
card = null,
language = null,
filtered = emptyList()
)
fun mockStatusViewData(
id: String = "100",
inReplyToId: String? = null,
inReplyToAccountId: String? = null,
isDetailed: Boolean = false,
spoilerText: String = "",
isExpanded: Boolean = false,
isShowingContent: Boolean = false,
isCollapsed: Boolean = !isDetailed,
reblogged: Boolean = false,
favourited: Boolean = true,
bookmarked: Boolean = true
) = StatusViewData.Concrete(
status = mockStatus(
id = id,
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
spoilerText = spoilerText,
reblogged = reblogged,
favourited = favourited,
bookmarked = bookmarked
),
isExpanded = isExpanded,
isShowingContent = isShowingContent,
isCollapsed = isCollapsed,
isDetailed = isDetailed
)
fun mockStatusEntityWithAccount(
id: String = "100",
userId: Long = 1,
expanded: Boolean = false
): TimelineStatusWithAccount {
val mockedStatus = mockStatus(id)
val moshi = NetworkModule.providesMoshi()
return TimelineStatusWithAccount(
status = mockedStatus.toEntity(
timelineUserId = userId,
moshi = moshi,
expanded = expanded,
contentShowing = false,
contentCollapsed = true
),
account = mockedStatus.account.toEntity(
accountId = userId,
moshi = moshi
)
)
}
fun mockPlaceholderEntityWithAccount(
id: String,
userId: Long = 1
): TimelineStatusWithAccount {
return TimelineStatusWithAccount(
status = Placeholder(id, false).toEntity(userId)
)
}

View file

@ -0,0 +1,189 @@
package com.keylesspalace.tusky.components.timeline
import androidx.paging.PagingSource
import androidx.room.withTransaction
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.entity.HomeTimelineData
import com.keylesspalace.tusky.db.entity.HomeTimelineEntity
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date
import org.junit.Assert.assertEquals
private val fixedDate = Date(1638889052000)
fun fakeAccount(
id: String = "100",
domain: String = "mastodon.example"
) = TimelineAccount(
id = id,
localUsername = "connyduck",
username = "connyduck@$domain",
displayName = "Conny Duck",
note = "This is their bio",
url = "https://$domain/@ConnyDuck",
avatar = "https://$domain/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg"
)
fun fakeStatus(
id: String = "100",
authorServerId: String = "100",
inReplyToId: String? = null,
inReplyToAccountId: String? = null,
spoilerText: String = "",
reblogged: Boolean = false,
favourited: Boolean = true,
bookmarked: Boolean = true,
domain: String = "mastodon.example"
) = Status(
id = id,
url = "https://$domain/@ConnyDuck/$id",
account = fakeAccount(
id = authorServerId,
domain = domain
),
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
reblog = null,
content = "Test",
createdAt = fixedDate,
editedAt = null,
emojis = emptyList(),
reblogsCount = 1,
favouritesCount = 2,
repliesCount = 3,
reblogged = reblogged,
favourited = favourited,
bookmarked = bookmarked,
sensitive = true,
spoilerText = spoilerText,
visibility = Status.Visibility.PUBLIC,
attachments = ArrayList(),
mentions = emptyList(),
tags = emptyList(),
application = Status.Application("Tusky", "https://tusky.app"),
pinned = false,
muted = false,
poll = null,
card = null,
language = null,
filtered = emptyList()
)
fun fakeStatusViewData(
id: String = "100",
inReplyToId: String? = null,
inReplyToAccountId: String? = null,
isDetailed: Boolean = false,
spoilerText: String = "",
isExpanded: Boolean = false,
isShowingContent: Boolean = false,
isCollapsed: Boolean = !isDetailed,
reblogged: Boolean = false,
favourited: Boolean = true,
bookmarked: Boolean = true
) = StatusViewData.Concrete(
status = fakeStatus(
id = id,
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
spoilerText = spoilerText,
reblogged = reblogged,
favourited = favourited,
bookmarked = bookmarked
),
isExpanded = isExpanded,
isShowingContent = isShowingContent,
isCollapsed = isCollapsed,
isDetailed = isDetailed
)
fun fakeHomeTimelineData(
id: String = "100",
statusId: String = id,
tuskyAccountId: Long = 1,
authorServerId: String = "100",
expanded: Boolean = false,
domain: String = "mastodon.example",
reblogAuthorServerId: String? = null
): HomeTimelineData {
val mockedStatus = fakeStatus(
id = statusId,
authorServerId = authorServerId,
domain = domain
)
return HomeTimelineData(
id = id,
status = mockedStatus.toEntity(
tuskyAccountId = tuskyAccountId,
expanded = expanded,
contentShowing = false,
contentCollapsed = true
),
account = mockedStatus.account.toEntity(
tuskyAccountId = tuskyAccountId,
),
reblogAccount = reblogAuthorServerId?.let { reblogAuthorId ->
fakeAccount(
id = reblogAuthorId
).toEntity(
tuskyAccountId = tuskyAccountId,
)
},
loading = false
)
}
fun fakePlaceholderHomeTimelineData(
id: String
) = HomeTimelineData(
id = id,
account = null,
status = null,
reblogAccount = null,
loading = false
)
suspend fun AppDatabase.insert(timelineItems: List<HomeTimelineData>, tuskyAccountId: Long = 1) = withTransaction {
timelineItems.forEach { timelineItem ->
timelineItem.account?.let { account ->
timelineAccountDao().insert(account)
}
timelineItem.reblogAccount?.let { account ->
timelineAccountDao().insert(account)
}
timelineItem.status?.let { status ->
timelineStatusDao().insert(status)
}
timelineDao().insertHomeTimelineItem(
HomeTimelineEntity(
tuskyAccountId = tuskyAccountId,
id = timelineItem.id,
statusId = timelineItem.status?.serverId,
reblogAccountId = timelineItem.reblogAccount?.serverId,
loading = timelineItem.loading
)
)
}
}
suspend fun AppDatabase.assertTimeline(
expected: List<HomeTimelineData>,
tuskyAccountId: Long = 1
) {
val pagingSource = timelineDao().getHomeTimeline(tuskyAccountId)
val loadResult = 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)
assertEquals(exp.account, prov.account)
assertEquals(exp.reblogAccount, prov.reblogAccount)
}
}

View file

@ -8,12 +8,12 @@ import androidx.test.platform.app.InstrumentationRegistry
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.StatusChangedEvent
import com.keylesspalace.tusky.components.timeline.mockStatus
import com.keylesspalace.tusky.components.timeline.mockStatusViewData
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.components.timeline.fakeStatus
import com.keylesspalace.tusky.components.timeline.fakeStatusViewData
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.di.NetworkModule
import com.keylesspalace.tusky.entity.StatusContext
import com.keylesspalace.tusky.network.FilterModel
@ -118,9 +118,9 @@ class ViewThreadViewModelTest {
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
fakeStatusViewData(id = "1", spoilerText = "Test"),
fakeStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"),
fakeStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
@ -133,7 +133,7 @@ class ViewThreadViewModelTest {
@Test
fun `should emit status even if context fails to load`() {
api.stub {
onBlocking { status(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1"))
onBlocking { status(threadId) } doReturn NetworkResult.success(fakeStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1"))
onBlocking { statusContext(threadId) } doReturn NetworkResult.failure(IOException())
}
@ -143,7 +143,7 @@ class ViewThreadViewModelTest {
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true)
fakeStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true)
),
detailedStatusPosition = 0,
revealButton = RevealButtonState.NO_BUTTON
@ -176,8 +176,8 @@ class ViewThreadViewModelTest {
onBlocking { status(threadId) } doReturn NetworkResult.failure(IOException())
onBlocking { statusContext(threadId) } doReturn NetworkResult.success(
StatusContext(
ancestors = listOf(mockStatus(id = "1")),
descendants = listOf(mockStatus(id = "3", inReplyToId = "2", inReplyToAccountId = "1"))
ancestors = listOf(fakeStatus(id = "1")),
descendants = listOf(fakeStatus(id = "3", inReplyToId = "2", inReplyToAccountId = "1"))
)
)
}
@ -203,9 +203,9 @@ class ViewThreadViewModelTest {
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test", isExpanded = true),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isExpanded = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test", isExpanded = true)
fakeStatusViewData(id = "1", spoilerText = "Test", isExpanded = true),
fakeStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isExpanded = true),
fakeStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test", isExpanded = true)
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.HIDE
@ -222,14 +222,14 @@ class ViewThreadViewModelTest {
viewModel.loadThread(threadId)
runBlocking {
eventHub.dispatch(StatusChangedEvent(mockStatus(id = "1", spoilerText = "Test", favourited = false)))
eventHub.dispatch(StatusChangedEvent(fakeStatus(id = "1", spoilerText = "Test", favourited = false)))
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test", favourited = false),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
fakeStatusViewData(id = "1", spoilerText = "Test", favourited = false),
fakeStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"),
fakeStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
@ -245,14 +245,14 @@ class ViewThreadViewModelTest {
viewModel.loadThread(threadId)
viewModel.removeStatus(mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test"))
viewModel.removeStatus(fakeStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test"))
runBlocking {
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
fakeStatusViewData(id = "1", spoilerText = "Test"),
fakeStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
@ -270,16 +270,16 @@ class ViewThreadViewModelTest {
viewModel.changeExpanded(
true,
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
fakeStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
)
runBlocking {
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isExpanded = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
fakeStatusViewData(id = "1", spoilerText = "Test"),
fakeStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isExpanded = true),
fakeStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
@ -297,16 +297,16 @@ class ViewThreadViewModelTest {
viewModel.changeContentCollapsed(
true,
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
fakeStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
)
runBlocking {
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isCollapsed = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
fakeStatusViewData(id = "1", spoilerText = "Test"),
fakeStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isCollapsed = true),
fakeStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
@ -324,16 +324,16 @@ class ViewThreadViewModelTest {
viewModel.changeContentShowing(
true,
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
fakeStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
)
runBlocking {
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isShowingContent = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
fakeStatusViewData(id = "1", spoilerText = "Test"),
fakeStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isShowingContent = true),
fakeStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
@ -345,11 +345,11 @@ class ViewThreadViewModelTest {
private fun mockSuccessResponses() {
api.stub {
onBlocking { status(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1", spoilerText = "Test"))
onBlocking { status(threadId) } doReturn NetworkResult.success(fakeStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1", spoilerText = "Test"))
onBlocking { statusContext(threadId) } doReturn NetworkResult.success(
StatusContext(
ancestors = listOf(mockStatus(id = "1", spoilerText = "Test")),
descendants = listOf(mockStatus(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test"))
ancestors = listOf(fakeStatus(id = "1", spoilerText = "Test")),
descendants = listOf(fakeStatus(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test"))
)
)
}

View file

@ -1,490 +0,0 @@
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.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.di.NetworkModule
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
private val moshi = NetworkModule.providesMoshi()
@Before
fun createDb() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.addTypeConverter(Converters(moshi))
.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.getStatuses(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 statusesBeforeCleanup = listOf(
makeStatus(statusId = 100),
makeStatus(statusId = 10, authorServerId = "3"),
makeStatus(statusId = 8, reblog = true, authorServerId = "10"),
makeStatus(statusId = 5),
makeStatus(statusId = 3, authorServerId = "4"),
makeStatus(statusId = 2, accountId = 2, authorServerId = "5"),
makeStatus(statusId = 1, authorServerId = "5")
)
val statusesAfterCleanup = listOf(
makeStatus(statusId = 100),
makeStatus(statusId = 10, authorServerId = "3"),
makeStatus(statusId = 8, reblog = true, authorServerId = "10"),
makeStatus(statusId = 2, accountId = 2, authorServerId = "5")
)
for ((status, author, reblogAuthor) in statusesBeforeCleanup) {
timelineDao.insertAccount(author)
reblogAuthor?.let {
timelineDao.insertAccount(it)
}
timelineDao.insertStatus(status)
}
timelineDao.cleanup(accountId = 1, limit = 3)
timelineDao.cleanupAccounts(accountId = 1)
val loadParams: PagingSource.LoadParams<Int> = PagingSource.LoadParams.Refresh(null, 100, false)
val loadedStatuses = (timelineDao.getStatuses(1).load(loadParams) as PagingSource.LoadResult.Page).data
assertStatuses(statusesAfterCleanup, loadedStatuses)
val loadedAccounts: MutableList<Pair<Long, String>> = mutableListOf()
val accountCursor = db.query("SELECT timelineUserId, serverId FROM TimelineAccountEntity ORDER BY timelineUserId, serverId", null)
accountCursor.moveToFirst()
while (!accountCursor.isAfterLast) {
val accountId: Long = accountCursor.getLong(accountCursor.getColumnIndex("timelineUserId"))
val serverId: String = accountCursor.getString(accountCursor.getColumnIndex("serverId"))
loadedAccounts.add(accountId to serverId)
accountCursor.moveToNext()
}
val expectedAccounts = listOf(
1L to "10",
1L to "20",
1L to "3",
1L to "R10",
2L to "5"
)
assertEquals(expectedAccounts, loadedAccounts)
}
@Test
fun overwriteDeletedStatus() = runBlocking {
val oldStatuses = listOf(
makeStatus(statusId = 3),
makeStatus(statusId = 2),
makeStatus(statusId = 1)
)
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)
)
val deletedCount = timelineDao.deleteRange(1, newStatuses.last().first.serverId, newStatuses.first().first.serverId)
assertEquals(3, deletedCount)
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.getStatuses(1)
val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 100, false))
val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data
assertStatuses(newStatuses, loadedStatuses)
}
@Test
fun deleteRange() = runBlocking {
val statuses = listOf(
makeStatus(statusId = 100),
makeStatus(statusId = 50),
makeStatus(statusId = 15),
makeStatus(statusId = 14),
makeStatus(statusId = 13),
makeStatus(statusId = 13, accountId = 2),
makeStatus(statusId = 12),
makeStatus(statusId = 11),
makeStatus(statusId = 9)
)
for ((status, author, reblogAuthor) in statuses) {
timelineDao.insertAccount(author)
reblogAuthor?.let {
timelineDao.insertAccount(it)
}
timelineDao.insertStatus(status)
}
assertEquals(3, timelineDao.deleteRange(1, "12", "14"))
assertEquals(0, timelineDao.deleteRange(1, "80", "80"))
assertEquals(0, timelineDao.deleteRange(1, "60", "80"))
assertEquals(0, timelineDao.deleteRange(1, "5", "8"))
assertEquals(0, timelineDao.deleteRange(1, "101", "1000"))
assertEquals(1, timelineDao.deleteRange(1, "50", "50"))
val loadParams: PagingSource.LoadParams<Int> = PagingSource.LoadParams.Refresh(null, 100, false)
val statusesAccount1 = (timelineDao.getStatuses(1).load(loadParams) as PagingSource.LoadResult.Page).data
val statusesAccount2 = (timelineDao.getStatuses(2).load(loadParams) as PagingSource.LoadResult.Page).data
val remainingStatusesAccount1 = listOf(
makeStatus(statusId = 100),
makeStatus(statusId = 15),
makeStatus(statusId = 11),
makeStatus(statusId = 9)
)
val remainingStatusesAccount2 = listOf(
makeStatus(statusId = 13, accountId = 2)
)
assertStatuses(remainingStatusesAccount1, statusesAccount1)
assertStatuses(remainingStatusesAccount2, statusesAccount2)
}
@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.getStatuses(1).load(loadParams) as PagingSource.LoadResult.Page).data
val statusesAccount2 = (timelineDao.getStatuses(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 statusData = listOf(
makeStatus(
statusId = 4,
accountId = 1,
domain = "mastodon.test",
authorServerId = "1"
),
makeStatus(
statusId = 33,
accountId = 1,
domain = "mastodon.test",
authorServerId = "2"
),
makeStatus(
statusId = 22,
accountId = 1,
domain = "mastodon.test",
authorServerId = "2"
)
)
for ((status, author, reblogAuthor) in statusData) {
timelineDao.insertAccount(author)
reblogAuthor?.let {
timelineDao.insertAccount(it)
}
timelineDao.insertStatus(status)
}
assertEquals("33", timelineDao.getTopId(1))
}
@Test
fun `should return correct placeholderId after other ids`() = runBlocking {
val statusData = listOf(
makeStatus(statusId = 1000),
makePlaceholder(id = 99),
makeStatus(statusId = 97),
makeStatus(statusId = 95),
makePlaceholder(id = 94),
makeStatus(statusId = 90)
)
for ((status, author, reblogAuthor) in statusData) {
author?.let {
timelineDao.insertAccount(it)
}
reblogAuthor?.let {
timelineDao.insertAccount(it)
}
timelineDao.insertStatus(status)
}
assertEquals("99", timelineDao.getNextPlaceholderIdAfter(1, "1000"))
assertEquals("94", timelineDao.getNextPlaceholderIdAfter(1, "99"))
assertNull(timelineDao.getNextPlaceholderIdAfter(1, "90"))
}
@Test
fun `should return correct top placeholderId`() = runBlocking {
val statusData = listOf(
makeStatus(statusId = 1000),
makePlaceholder(id = 99),
makeStatus(statusId = 97),
makePlaceholder(id = 96),
makeStatus(statusId = 90),
makePlaceholder(id = 80),
makeStatus(statusId = 77)
)
for ((status, author, reblogAuthor) in statusData) {
author?.let {
timelineDao.insertAccount(it)
}
reblogAuthor?.let {
timelineDao.insertAccount(it)
}
timelineDao.insertStatus(status)
}
assertEquals("99", timelineDao.getTopPlaceholderId(1))
}
@Test
fun `preview card survives roundtrip`() = runBlocking {
val setOne = makeStatus(statusId = 3, cardUrl = "https://foo.bar")
for ((status, author, reblogger) in listOf(setOne)) {
timelineDao.insertAccount(author)
reblogger?.let {
timelineDao.insertAccount(it)
}
timelineDao.insertStatus(status)
}
val pagingSource = timelineDao.getStatuses(setOne.first.timelineUserId)
val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 2, false))
val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data
assertEquals(1, loadedStatuses.size)
assertStatuses(listOf(setOne), loadedStatuses)
}
private fun makeStatus(
accountId: Long = 1,
statusId: Long = 10,
reblog: Boolean = false,
createdAt: Long = statusId,
authorServerId: String = "20",
domain: String = "mastodon.example",
cardUrl: String? = null
): Triple<TimelineStatusEntity, TimelineAccountEntity, TimelineAccountEntity?> {
val author = TimelineAccountEntity(
serverId = authorServerId,
timelineUserId = accountId,
localUsername = "localUsername@$domain",
username = "username@$domain",
displayName = "displayName",
url = "blah",
avatar = "avatar",
emojis = "[\"tusky\": \"http://tusky.cool/emoji.jpg\"]",
bot = false
)
val reblogAuthor = if (reblog) {
TimelineAccountEntity(
serverId = "R$authorServerId",
timelineUserId = accountId,
localUsername = "RlocalUsername",
username = "Rusername",
displayName = "RdisplayName",
url = "Rblah",
avatar = "Ravatar",
emojis = "[]",
bot = false
)
} else {
null
}
val card = when (cardUrl) {
null -> null
else -> "{ url: \"$cardUrl\" }"
}
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,
editedAt = null,
emojis = "emojis$statusId",
reblogsCount = 1 * statusId.toInt(),
favouritesCount = 2 * statusId.toInt(),
repliesCount = 3 * statusId.toInt(),
reblogged = even,
favourited = !even,
bookmarked = false,
sensitive = even,
spoilerText = "spoiler$statusId",
visibility = Status.Visibility.PRIVATE,
attachments = "attachments$accountId",
mentions = "mentions$accountId",
tags = "tags$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,
card = card,
language = null,
filtered = null
)
return Triple(status, author, reblogAuthor)
}
private fun makePlaceholder(
accountId: Long = 1,
id: Long
): Triple<TimelineStatusEntity, TimelineAccountEntity?, TimelineAccountEntity?> {
val placeholder = Placeholder(id.toString(), false).toEntity(accountId)
return Triple(placeholder, null, null)
}
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)
}
}
}

View file

@ -0,0 +1,229 @@
package com.keylesspalace.tusky.db.dao
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.keylesspalace.tusky.components.notifications.fakeNotification
import com.keylesspalace.tusky.components.notifications.fakeReport
import com.keylesspalace.tusky.components.notifications.insert
import com.keylesspalace.tusky.components.timeline.fakeAccount
import com.keylesspalace.tusky.components.timeline.fakeHomeTimelineData
import com.keylesspalace.tusky.components.timeline.fakeStatus
import com.keylesspalace.tusky.components.timeline.insert
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.db.DatabaseCleaner
import com.keylesspalace.tusky.db.entity.HomeTimelineEntity
import com.keylesspalace.tusky.db.entity.NotificationEntity
import com.keylesspalace.tusky.db.entity.NotificationReportEntity
import com.keylesspalace.tusky.db.entity.TimelineAccountEntity
import com.keylesspalace.tusky.db.entity.TimelineStatusEntity
import com.keylesspalace.tusky.di.NetworkModule
import kotlin.reflect.KClass
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
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 DatabaseCleanerTest {
private lateinit var timelineDao: TimelineDao
private lateinit var dbCleaner: DatabaseCleaner
private lateinit var db: AppDatabase
private val moshi = NetworkModule.providesMoshi()
@Before
fun createDb() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.addTypeConverter(Converters(moshi))
.allowMainThreadQueries()
.build()
timelineDao = db.timelineDao()
dbCleaner = DatabaseCleaner(db)
}
@After
fun closeDb() {
db.close()
}
@Test
fun cleanupOldData() = runTest {
fillDatabase()
dbCleaner.cleanupOldData(tuskyAccountId = 1, timelineLimit = 3, notificationLimit = 3)
// all but 3 timeline items and notifications and all references items should be gone for Tusky account 1
// items of Tusky account 2 should be untouched
expect(
hometimelineItems = listOf(
1L to "10",
1L to "100",
1L to "8",
2L to "2"
),
statuses = listOf(
1L to "10",
1L to "100",
1L to "8",
1L to "n3",
1L to "n4",
1L to "n5",
2L to "2",
2L to "n1",
2L to "n2",
2L to "n3",
2L to "n4"
),
notifications = listOf(
1L to "3",
1L to "4",
1L to "5",
2L to "1",
2L to "2",
2L to "3",
2L to "4",
),
accounts = listOf(
1L to "10",
1L to "100",
1L to "3",
1L to "R10",
1L to "n3",
1L to "n4",
1L to "n5",
1L to "r2",
2L to "100",
2L to "5",
2L to "n1",
2L to "n2",
2L to "n3",
2L to "n4",
2L to "r1"
),
reports = listOf(
1L to "2",
2L to "1"
),
)
}
@Test
fun cleanupEverything() = runTest {
fillDatabase()
dbCleaner.cleanupEverything(tuskyAccountId = 1)
// everything from Tusky account 1 should be gone
// items of Tusky account 2 should be untouched
expect(
hometimelineItems = listOf(
2L to "2"
),
statuses = listOf(
2L to "2",
2L to "n1",
2L to "n2",
2L to "n3",
2L to "n4"
),
notifications = listOf(
2L to "1",
2L to "2",
2L to "3",
2L to "4",
),
accounts = listOf(
2L to "100",
2L to "5",
2L to "n1",
2L to "n2",
2L to "n3",
2L to "n4",
2L to "r1"
),
reports = listOf(
2L to "1"
),
)
}
private suspend fun fillDatabase() {
db.insert(
listOf(
fakeHomeTimelineData(id = "100", authorServerId = "100"),
fakeHomeTimelineData(id = "10", authorServerId = "3"),
fakeHomeTimelineData(id = "8", reblogAuthorServerId = "R10", authorServerId = "10"),
fakeHomeTimelineData(id = "5", authorServerId = "100"),
fakeHomeTimelineData(id = "3", authorServerId = "4"),
fakeHomeTimelineData(id = "1", authorServerId = "5")
),
tuskyAccountId = 1
)
db.insert(
listOf(
fakeHomeTimelineData(id = "2", tuskyAccountId = 2, authorServerId = "5")
),
tuskyAccountId = 2
)
db.insert(
listOf(
fakeNotification(id = "1", account = fakeAccount(id = "n1"), status = fakeStatus(id = "n1")),
fakeNotification(id = "2", account = fakeAccount(id = "n2"), status = fakeStatus(id = "n2"), report = fakeReport(targetAccount = fakeAccount(id = "r1"))),
fakeNotification(id = "3", account = fakeAccount(id = "n3"), status = fakeStatus(id = "n3")),
fakeNotification(id = "4", account = fakeAccount(id = "n4"), status = fakeStatus(id = "n4"), report = fakeReport(id = "2", targetAccount = fakeAccount(id = "r2"))),
fakeNotification(id = "5", account = fakeAccount(id = "n5"), status = fakeStatus(id = "n5")),
),
tuskyAccountId = 1
)
db.insert(
listOf(
fakeNotification(id = "1", account = fakeAccount(id = "n1"), status = fakeStatus(id = "n1")),
fakeNotification(id = "2", account = fakeAccount(id = "n2"), status = fakeStatus(id = "n2")),
fakeNotification(id = "3", account = fakeAccount(id = "n3"), status = fakeStatus(id = "n3")),
fakeNotification(id = "4", account = fakeAccount(id = "n4"), status = fakeStatus(id = "n4"), report = fakeReport(targetAccount = fakeAccount(id = "r1")))
),
tuskyAccountId = 2
)
}
private fun expect(
hometimelineItems: List<Pair<Long, String>>,
statuses: List<Pair<Long, String>>,
notifications: List<Pair<Long, String>>,
accounts: List<Pair<Long, String>>,
reports: List<Pair<Long, String>>,
) {
expect(HomeTimelineEntity::class, "id", hometimelineItems)
expect(TimelineStatusEntity::class, "serverId", statuses)
expect(NotificationEntity::class, "id", notifications)
expect(TimelineAccountEntity::class, "serverId", accounts)
expect(NotificationReportEntity::class, "serverId", reports)
}
private fun expect(
entity: KClass<*>,
idName: String,
expectedItems: List<Pair<Long, String>>
) {
val loadedItems: MutableList<Pair<Long, String>> = mutableListOf()
val cursor = db.query("SELECT tuskyAccountId, $idName FROM ${entity.simpleName} ORDER BY tuskyAccountId, $idName", null)
cursor.moveToFirst()
while (!cursor.isAfterLast) {
val tuskyAccountId: Long = cursor.getLong(cursor.getColumnIndex("tuskyAccountId"))
val id: String = cursor.getString(cursor.getColumnIndex(idName))
loadedItems.add(tuskyAccountId to id)
cursor.moveToNext()
}
cursor.close()
assertEquals(expectedItems, loadedItems)
}
}

View file

@ -0,0 +1,234 @@
package com.keylesspalace.tusky.db.dao
import androidx.paging.PagingSource
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.keylesspalace.tusky.components.notifications.fakeNotification
import com.keylesspalace.tusky.components.notifications.fakeReport
import com.keylesspalace.tusky.components.notifications.insert
import com.keylesspalace.tusky.components.notifications.toNotificationDataEntity
import com.keylesspalace.tusky.components.notifications.toNotificationEntity
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.fakeAccount
import com.keylesspalace.tusky.components.timeline.fakeStatus
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.di.NetworkModule
import com.keylesspalace.tusky.entity.Notification
import kotlinx.coroutines.test.runTest
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 NotificationsDaoTest {
private lateinit var notificationsDao: NotificationsDao
private lateinit var db: AppDatabase
private val moshi = NetworkModule.providesMoshi()
@Before
fun createDb() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.addTypeConverter(Converters(moshi))
.allowMainThreadQueries()
.build()
notificationsDao = db.notificationsDao()
}
@After
fun closeDb() {
db.close()
}
@Test
fun insertAndGetNotification() = runTest {
db.insert(
listOf(
fakeNotification(id = "1"),
fakeNotification(id = "2"),
fakeNotification(id = "3"),
),
tuskyAccountId = 1
)
db.insert(
listOf(fakeNotification(id = "3")),
tuskyAccountId = 2
)
val pagingSource = notificationsDao.getNotifications(tuskyAccountId = 1)
val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 2, false))
val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data
assertEquals(
listOf(
fakeNotification(id = "3").toNotificationDataEntity(1),
fakeNotification(id = "2").toNotificationDataEntity(1)
),
loadedStatuses
)
}
@Test
fun deleteRange() = runTest {
val notifications = listOf(
fakeNotification(id = "100"),
fakeNotification(id = "50"),
fakeNotification(id = "15"),
fakeNotification(id = "14"),
fakeNotification(id = "13"),
fakeNotification(id = "12"),
fakeNotification(id = "11"),
fakeNotification(id = "9")
)
db.insert(notifications, 1)
db.insert(listOf(fakeNotification(id = "13")), 2)
assertEquals(3, notificationsDao.deleteRange(1, "12", "14"))
assertEquals(0, notificationsDao.deleteRange(1, "80", "80"))
assertEquals(0, notificationsDao.deleteRange(1, "60", "80"))
assertEquals(0, notificationsDao.deleteRange(1, "5", "8"))
assertEquals(0, notificationsDao.deleteRange(1, "101", "1000"))
assertEquals(1, notificationsDao.deleteRange(1, "50", "50"))
val loadParams: PagingSource.LoadParams<Int> = PagingSource.LoadParams.Refresh(null, 100, false)
val notificationsAccount1 = (notificationsDao.getNotifications(1).load(loadParams) as PagingSource.LoadResult.Page).data
val notificationsAccount2 = (notificationsDao.getNotifications(2).load(loadParams) as PagingSource.LoadResult.Page).data
val remainingNotificationsAccount1 = listOf(
fakeNotification(id = "100").toNotificationDataEntity(1),
fakeNotification(id = "15").toNotificationDataEntity(1),
fakeNotification(id = "11").toNotificationDataEntity(1),
fakeNotification(id = "9").toNotificationDataEntity(1)
)
val remainingNotificationsAccount2 = listOf(
fakeNotification(id = "13").toNotificationDataEntity(2)
)
assertEquals(remainingNotificationsAccount1, notificationsAccount1)
assertEquals(remainingNotificationsAccount2, notificationsAccount2)
}
@Test
fun deleteAllForInstance() = runTest {
val redAccount = fakeNotification(id = "500", account = fakeAccount(id = "500", domain = "mastodon.red"))
val blueAccount = fakeNotification(id = "501", account = fakeAccount(id = "501", domain = "mastodon.blue"))
val redStatus = fakeNotification(id = "502", account = fakeAccount(id = "502", domain = "mastodon.example"), status = fakeStatus(id = "502", domain = "mastodon.red", authorServerId = "502a"))
val blueStatus = fakeNotification(id = "503", account = fakeAccount(id = "503", domain = "mastodon.example"), status = fakeStatus(id = "503", domain = "mastodon.blue", authorServerId = "503a"))
val redStatus2 = fakeNotification(id = "600", account = fakeAccount(id = "600", domain = "mastodon.red"))
db.insert(listOf(redAccount, blueAccount, redStatus, blueStatus), 1)
db.insert(listOf(redStatus2), 2)
notificationsDao.deleteAllFromInstance(1, "mastodon.red")
notificationsDao.deleteAllFromInstance(1, "mastodon.blu") // shouldn't delete anything
notificationsDao.deleteAllFromInstance(1, "mastodon.green") // shouldn't delete anything
val loadParams: PagingSource.LoadParams<Int> = PagingSource.LoadParams.Refresh(null, 100, false)
val notificationsAccount1 = (notificationsDao.getNotifications(1).load(loadParams) as PagingSource.LoadResult.Page).data
val notificationsAccount2 = (notificationsDao.getNotifications(2).load(loadParams) as PagingSource.LoadResult.Page).data
assertEquals(
listOf(
blueStatus.toNotificationDataEntity(1),
blueAccount.toNotificationDataEntity(1)
),
notificationsAccount1
)
assertEquals(listOf(redStatus2.toNotificationDataEntity(2)), notificationsAccount2)
}
@Test
fun `should return null as topId when db is empty`() = runTest {
assertNull(notificationsDao.getTopId(1))
}
@Test
fun `should return correct topId`() = runTest {
db.insert(
listOf(
fakeNotification(id = "100"),
fakeNotification(id = "3"),
fakeNotification(id = "33"),
fakeNotification(id = "8"),
),
tuskyAccountId = 1
)
db.insert(
listOf(
fakeNotification(id = "200"),
fakeNotification(id = "300"),
fakeNotification(id = "1000"),
),
tuskyAccountId = 2
)
assertEquals("100", notificationsDao.getTopId(1))
assertEquals("1000", notificationsDao.getTopId(2))
}
@Test
fun `should return correct top placeholderId`() = runTest {
val notifications = listOf(
fakeNotification(id = "1000"),
fakeNotification(id = "97"),
fakeNotification(id = "90"),
fakeNotification(id = "77")
)
db.insert(notifications)
notificationsDao.insertNotification(Placeholder(id = "99", loading = false).toNotificationEntity(1))
notificationsDao.insertNotification(Placeholder(id = "96", loading = false).toNotificationEntity(1))
notificationsDao.insertNotification(Placeholder(id = "80", loading = false).toNotificationEntity(1))
assertEquals("99", notificationsDao.getTopPlaceholderId(1))
}
@Test
fun `should correctly delete all by user`() = runTest {
val notificationsAccount1 = listOf(
// will be removed because it is a like by account 1
fakeNotification(id = "1", account = fakeAccount(id = "1"), status = fakeStatus(id = "1", authorServerId = "100")),
// will be removed because it references a status by account 1
fakeNotification(id = "2", account = fakeAccount(id = "2"), status = fakeStatus(id = "2", authorServerId = "1")),
// will not be removed because they are admin notifications
fakeNotification(type = Notification.Type.REPORT, id = "3", account = fakeAccount(id = "3"), status = null, report = fakeReport(id = "1", targetAccount = fakeAccount(id = "1"))),
fakeNotification(type = Notification.Type.SIGN_UP, id = "4", account = fakeAccount(id = "1"), status = null, report = fakeReport(id = "1", targetAccount = fakeAccount(id = "4"))),
// will not be removed because it does not reference account 1
fakeNotification(id = "5", account = fakeAccount(id = "5"), status = fakeStatus(id = "5", authorServerId = "100")),
)
db.insert(notificationsAccount1, tuskyAccountId = 1)
db.insert(listOf(fakeNotification(id = "6")), tuskyAccountId = 2)
notificationsDao.removeAllByUser(1, "1")
val loadedNotifications: MutableList<String> = mutableListOf()
val cursor = db.query("SELECT id FROM NotificationEntity ORDER BY id ASC", null)
cursor.moveToFirst()
while (!cursor.isAfterLast) {
val id: String = cursor.getString(cursor.getColumnIndex("id"))
loadedNotifications.add(id)
cursor.moveToNext()
}
cursor.close()
val expectedNotifications = listOf("3", "4", "5", "6")
assertEquals(expectedNotifications, loadedNotifications)
}
}

View file

@ -0,0 +1,340 @@
package com.keylesspalace.tusky.db.dao
import androidx.paging.PagingSource
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.keylesspalace.tusky.components.timeline.fakeHomeTimelineData
import com.keylesspalace.tusky.components.timeline.fakePlaceholderHomeTimelineData
import com.keylesspalace.tusky.components.timeline.insert
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.di.NetworkModule
import kotlinx.coroutines.test.runTest
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
private val moshi = NetworkModule.providesMoshi()
@Before
fun createDb() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.addTypeConverter(Converters(moshi))
.allowMainThreadQueries()
.build()
timelineDao = db.timelineDao()
}
@After
fun closeDb() {
db.close()
}
@Test
fun insertGetStatus() = runTest {
val setOne = fakeHomeTimelineData(id = "3")
val setTwo = fakeHomeTimelineData(id = "20", reblogAuthorServerId = "R1")
val ignoredOne = fakeHomeTimelineData(id = "1")
val ignoredTwo = fakeHomeTimelineData(id = "2", tuskyAccountId = 2)
db.insert(
listOf(setOne, setTwo, ignoredOne),
tuskyAccountId = 1
)
db.insert(
listOf(ignoredTwo),
tuskyAccountId = 2
)
val pagingSource = timelineDao.getHomeTimeline(1)
val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 2, false))
val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data
assertEquals(2, loadedStatuses.size)
assertEquals(listOf(setTwo, setOne), loadedStatuses)
}
@Test
fun overwriteDeletedStatus() = runTest {
val oldStatuses = listOf(
fakeHomeTimelineData(id = "3"),
fakeHomeTimelineData(id = "2"),
fakeHomeTimelineData(id = "1")
)
db.insert(oldStatuses, 1)
// status 2 gets deleted, newly loaded status contain only 1 + 3
val newStatuses = listOf(
fakeHomeTimelineData(id = "3"),
fakeHomeTimelineData(id = "1")
)
val deletedCount = timelineDao.deleteRange(1, newStatuses.last().id, newStatuses.first().id)
assertEquals(3, deletedCount)
db.insert(newStatuses, 1)
// make sure status 2 is no longer in db
val pagingSource = timelineDao.getHomeTimeline(1)
val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 100, false))
val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data
assertEquals(newStatuses, loadedStatuses)
}
@Test
fun deleteRange() = runTest {
val statuses = listOf(
fakeHomeTimelineData(id = "100"),
fakeHomeTimelineData(id = "50"),
fakeHomeTimelineData(id = "15"),
fakeHomeTimelineData(id = "14"),
fakeHomeTimelineData(id = "13"),
fakeHomeTimelineData(id = "13", tuskyAccountId = 2),
fakeHomeTimelineData(id = "12"),
fakeHomeTimelineData(id = "11"),
fakeHomeTimelineData(id = "9")
)
db.insert(statuses - statuses[5], 1)
db.insert(listOf(statuses[5]), 2)
assertEquals(3, timelineDao.deleteRange(1, "12", "14"))
assertEquals(0, timelineDao.deleteRange(1, "80", "80"))
assertEquals(0, timelineDao.deleteRange(1, "60", "80"))
assertEquals(0, timelineDao.deleteRange(1, "5", "8"))
assertEquals(0, timelineDao.deleteRange(1, "101", "1000"))
assertEquals(1, timelineDao.deleteRange(1, "50", "50"))
val loadParams: PagingSource.LoadParams<Int> = PagingSource.LoadParams.Refresh(null, 100, false)
val statusesAccount1 = (timelineDao.getHomeTimeline(1).load(loadParams) as PagingSource.LoadResult.Page).data
val statusesAccount2 = (timelineDao.getHomeTimeline(2).load(loadParams) as PagingSource.LoadResult.Page).data
val remainingStatusesAccount1 = listOf(
fakeHomeTimelineData(id = "100"),
fakeHomeTimelineData(id = "15"),
fakeHomeTimelineData(id = "11"),
fakeHomeTimelineData(id = "9")
)
val remainingStatusesAccount2 = listOf(
fakeHomeTimelineData(id = "13", tuskyAccountId = 2)
)
assertEquals(remainingStatusesAccount1, statusesAccount1)
assertEquals(remainingStatusesAccount2, statusesAccount2)
}
@Test
fun deleteAllForInstance() = runTest {
val statusWithRedDomain1 = fakeHomeTimelineData(
id = "15",
tuskyAccountId = 1,
domain = "mastodon.red",
authorServerId = "1"
)
val statusWithRedDomain2 = fakeHomeTimelineData(
id = "14",
tuskyAccountId = 1,
domain = "mastodon.red",
authorServerId = "2"
)
val statusWithRedDomainOtherAccount = fakeHomeTimelineData(
id = "12",
tuskyAccountId = 2,
domain = "mastodon.red",
authorServerId = "2"
)
val statusWithBlueDomain = fakeHomeTimelineData(
id = "10",
tuskyAccountId = 1,
domain = "mastodon.blue",
authorServerId = "4"
)
val statusWithBlueDomainOtherAccount = fakeHomeTimelineData(
id = "10",
tuskyAccountId = 2,
domain = "mastodon.blue",
authorServerId = "5"
)
val statusWithGreenDomain = fakeHomeTimelineData(
id = "8",
tuskyAccountId = 1,
domain = "mastodon.green",
authorServerId = "6"
)
db.insert(listOf(statusWithRedDomain1, statusWithRedDomain2, statusWithBlueDomain, statusWithGreenDomain), 1)
db.insert(listOf(statusWithRedDomainOtherAccount, statusWithBlueDomainOtherAccount), 2)
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.getHomeTimeline(1).load(loadParams) as PagingSource.LoadResult.Page).data
val statusesAccount2 = (timelineDao.getHomeTimeline(2).load(loadParams) as PagingSource.LoadResult.Page).data
assertEquals(listOf(statusWithBlueDomain, statusWithGreenDomain), statusesAccount1)
assertEquals(listOf(statusWithRedDomainOtherAccount, statusWithBlueDomainOtherAccount), statusesAccount2)
}
@Test
fun `should return null as topId when db is empty`() = runTest {
assertNull(timelineDao.getTopId(1))
}
@Test
fun `should return correct topId`() = runTest {
val statusData = listOf(
fakeHomeTimelineData(
id = "4",
tuskyAccountId = 1,
domain = "mastodon.test",
authorServerId = "1"
),
fakeHomeTimelineData(
id = "33",
tuskyAccountId = 1,
domain = "mastodon.test",
authorServerId = "2"
),
fakeHomeTimelineData(
id = "22",
tuskyAccountId = 1,
domain = "mastodon.test",
authorServerId = "2"
)
)
db.insert(statusData, 1)
assertEquals("33", timelineDao.getTopId(1))
}
@Test
fun `should return correct top placeholderId`() = runTest {
val statusData = listOf(
fakeHomeTimelineData(id = "1000"),
fakePlaceholderHomeTimelineData(id = "99"),
fakeHomeTimelineData(id = "97"),
fakePlaceholderHomeTimelineData(id = "96"),
fakeHomeTimelineData(id = "90"),
fakePlaceholderHomeTimelineData(id = "80"),
fakeHomeTimelineData(id = "77")
)
db.insert(statusData)
assertEquals("99", timelineDao.getTopPlaceholderId(1))
}
@Test
fun `should correctly delete all by user`() = runTest {
val statusData = listOf(
// will be deleted because it is a direct post
fakeHomeTimelineData(id = "0", tuskyAccountId = 1, authorServerId = "1"),
// different Tusky Account
fakeHomeTimelineData(id = "1", tuskyAccountId = 2, authorServerId = "1"),
// different author
fakeHomeTimelineData(id = "2", tuskyAccountId = 1, authorServerId = "2"),
// different author and reblogger
fakeHomeTimelineData(id = "3", tuskyAccountId = 1, authorServerId = "2", statusId = "100", reblogAuthorServerId = "3"),
// will be deleted because it is a reblog
fakeHomeTimelineData(id = "4", tuskyAccountId = 1, authorServerId = "2", statusId = "101", reblogAuthorServerId = "1"),
// not a status
fakePlaceholderHomeTimelineData(id = "5"),
// will be deleted because it is a self reblog
fakeHomeTimelineData(id = "6", tuskyAccountId = 1, authorServerId = "1", statusId = "102", reblogAuthorServerId = "1"),
// will be deleted because it direct post reblogged by another user
fakeHomeTimelineData(id = "7", tuskyAccountId = 1, authorServerId = "1", statusId = "103", reblogAuthorServerId = "3"),
// different Tusky Account
fakeHomeTimelineData(id = "8", tuskyAccountId = 2, authorServerId = "3", statusId = "104", reblogAuthorServerId = "2"),
// different Tusky Account
fakeHomeTimelineData(id = "9", tuskyAccountId = 2, authorServerId = "3", statusId = "105", reblogAuthorServerId = "1"),
)
db.insert(statusData - statusData[1] - statusData[8] - statusData [9], tuskyAccountId = 1)
db.insert(listOf(statusData[1], statusData[8], statusData [9]), tuskyAccountId = 2)
timelineDao.removeAllByUser(1, "1")
val loadedHomeTimelineItems: MutableList<String> = mutableListOf()
val accountCursor = db.query("SELECT id FROM HomeTimelineEntity ORDER BY id ASC", null)
accountCursor.moveToFirst()
while (!accountCursor.isAfterLast) {
val id: String = accountCursor.getString(accountCursor.getColumnIndex("id"))
loadedHomeTimelineItems.add(id)
accountCursor.moveToNext()
}
accountCursor.close()
val expectedHomeTimelineItems = listOf("1", "2", "3", "5", "8", "9")
assertEquals(expectedHomeTimelineItems, loadedHomeTimelineItems)
}
@Test
fun `should correctly delete statuses and reblogs by user`() = runTest {
val statusData = listOf(
// will be deleted because it is a direct post
fakeHomeTimelineData(id = "0", tuskyAccountId = 1, authorServerId = "1"),
// different Tusky Account
fakeHomeTimelineData(id = "1", tuskyAccountId = 2, authorServerId = "1"),
// different author
fakeHomeTimelineData(id = "2", tuskyAccountId = 1, authorServerId = "2"),
// different author and reblogger
fakeHomeTimelineData(id = "3", tuskyAccountId = 1, authorServerId = "2", statusId = "100", reblogAuthorServerId = "3"),
// will be deleted because it is a reblog
fakeHomeTimelineData(id = "4", tuskyAccountId = 1, authorServerId = "2", statusId = "101", reblogAuthorServerId = "1"),
// not a status
fakePlaceholderHomeTimelineData(id = "5"),
// will be deleted because it is a self reblog
fakeHomeTimelineData(id = "6", tuskyAccountId = 1, authorServerId = "1", statusId = "102", reblogAuthorServerId = "1"),
// will NOT be deleted because it direct post reblogged by another user
fakeHomeTimelineData(id = "7", tuskyAccountId = 1, authorServerId = "1", statusId = "103", reblogAuthorServerId = "3"),
// different Tusky Account
fakeHomeTimelineData(id = "8", tuskyAccountId = 2, authorServerId = "3", statusId = "104", reblogAuthorServerId = "2"),
// different Tusky Account
fakeHomeTimelineData(id = "9", tuskyAccountId = 2, authorServerId = "3", statusId = "105", reblogAuthorServerId = "1"),
)
db.insert(statusData - statusData[1] - statusData[8] - statusData [9], tuskyAccountId = 1)
db.insert(listOf(statusData[1], statusData[8], statusData [9]), tuskyAccountId = 2)
timelineDao.removeStatusesAndReblogsByUser(1, "1")
val loadedHomeTimelineItems: MutableList<String> = mutableListOf()
val accountCursor = db.query("SELECT id FROM HomeTimelineEntity ORDER BY id ASC", null)
accountCursor.moveToFirst()
while (!accountCursor.isAfterLast) {
val id: String = accountCursor.getString(accountCursor.getColumnIndex("id"))
loadedHomeTimelineItems.add(id)
accountCursor.moveToNext()
}
accountCursor.close()
val expectedHomeTimelineItems = listOf("1", "2", "3", "5", "7", "8", "9")
assertEquals(expectedHomeTimelineItems, loadedHomeTimelineItems)
}
}

View file

@ -1,7 +1,7 @@
package com.keylesspalace.tusky.network
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.entity.AccountEntity
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse

View file

@ -1,55 +0,0 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.util
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class)
class FlowExtensionsTest {
@Test
fun `throttleFirst throttles first`() = runTest {
flow {
emit(1) // t = 0, emitted
delay(90.milliseconds)
emit(2) // throttled, t = 90
delay(90.milliseconds)
emit(3) // throttled, t == 180
delay(1010.milliseconds)
emit(4) // t = 1190, emitted
delay(1010.milliseconds)
emit(5) // t = 2200, emitted
}
.throttleFirst(1000.milliseconds, timeSource = testScheduler.timeSource)
.test {
advanceUntilIdle()
assertThat(awaitItem()).isEqualTo(1)
assertThat(awaitItem()).isEqualTo(4)
assertThat(awaitItem()).isEqualTo(5)
awaitComplete()
}
}
}

View file

@ -3,7 +3,7 @@ package com.keylesspalace.tusky.util
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.entity.AccountEntity
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith