Merge tag 'v28.0' into develop

# Conflicts:
#	README.md
#	app/build.gradle
#	app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java
#	app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.kt
#	app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt
#	app/src/main/res/color/compound_button_color.xml
#	app/src/main/res/color/text_input_layout_box_stroke_color.xml
#	app/src/main/res/drawable/ic_check_circle.xml
#	app/src/main/res/drawable/ic_flag_24dp.xml
#	app/src/main/res/drawable/ic_person_add_24dp.xml
#	app/src/main/res/drawable/ic_play_indicator.xml
#	app/src/main/res/drawable/ic_poll_24dp.xml
#	app/src/main/res/drawable/ic_reblog_active_24dp.xml
#	app/src/main/res/drawable/ic_reblog_private_active_24dp.xml
#	app/src/main/res/drawable/report_success_background.xml
#	app/src/main/res/layout-land/item_trending_cell.xml
#	app/src/main/res/layout/activity_account.xml
#	app/src/main/res/layout/activity_edit_filter.xml
#	app/src/main/res/layout/card_license.xml
#	app/src/main/res/layout/item_announcement.xml
#	app/src/main/res/layout/item_status.xml
#	app/src/main/res/layout/item_status_detailed.xml
#	app/src/main/res/layout/item_tab_preference.xml
#	app/src/main/res/layout/item_trending_cell.xml
#	app/src/main/res/values-cs/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-eu/strings.xml
#	app/src/main/res/values-fr/strings.xml
#	app/src/main/res/values-kab/strings.xml
#	app/src/main/res/values-lv/strings.xml
#	app/src/main/res/values-nb-rNO/strings.xml
#	app/src/main/res/values-night/theme_colors.xml
#	app/src/main/res/values/colors.xml
#	app/src/main/res/values/strings.xml
#	app/src/main/res/values/styles.xml
#	app/src/main/res/values/theme_colors.xml
This commit is contained in:
Mike Barnes 2026-01-03 09:57:39 +11:00
commit a66f7bb515
614 changed files with 52429 additions and 19916 deletions

View file

@ -18,7 +18,8 @@
package com.keylesspalace.tusky
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.components.filters.EditFilterActivity
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
@ -26,16 +27,22 @@ import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.PollOption
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import java.time.Instant
import java.util.Date
import kotlinx.coroutines.runBlocking
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.robolectric.annotation.Config
import retrofit2.HttpException
import retrofit2.Response
@Config(sdk = [28])
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class FilterV1Test {
@ -43,12 +50,11 @@ class FilterV1Test {
@Before
fun setup() {
filterModel = FilterModel()
val filters = listOf(
FilterV1(
id = "123",
phrase = "badWord",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
expiresAt = null,
irreversible = false,
wholeWord = false
@ -56,7 +62,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "badWholeWord",
context = listOf(FilterV1.HOME, FilterV1.PUBLIC),
context = listOf(Filter.Kind.HOME.kind, Filter.Kind.PUBLIC.kind),
expiresAt = null,
irreversible = false,
wholeWord = true
@ -64,7 +70,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "@twitter.com",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
expiresAt = null,
irreversible = false,
wholeWord = true
@ -72,7 +78,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "#hashtag",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
expiresAt = null,
irreversible = false,
wholeWord = true
@ -80,7 +86,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "expired",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
expiresAt = Date.from(Instant.now().minusSeconds(10)),
irreversible = false,
wholeWord = true
@ -88,7 +94,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "unexpired",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
expiresAt = Date.from(Instant.now().plusSeconds(3600)),
irreversible = false,
wholeWord = true
@ -96,14 +102,27 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "href",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
expiresAt = null,
irreversible = false,
wholeWord = false
)
)
filterModel.initWithFilters(filters)
val api: MastodonApi = mock {
onBlocking { getFiltersV1() } doReturn NetworkResult.success(filters)
onBlocking { getFilters() } doReturn NetworkResult.failure(
HttpException(Response.error<Any>(404, "".toResponseBody()))
)
}
val instanceInfoRepo: InstanceInfoRepository = mock {
onBlocking { isFilterV2Supported() } doReturn false
}
filterModel = FilterModel(instanceInfoRepo, api)
runBlocking {
filterModel.init(Filter.Kind.HOME)
}
}
@Test
@ -257,22 +276,6 @@ class FilterV1Test {
)
}
@Test
fun unchangedExpiration_shouldBeNegative_whenFilterIsExpired() {
val expiredBySeconds = 3600
val expiredDate = Date.from(Instant.now().minusSeconds(expiredBySeconds.toLong()))
val updatedDuration = EditFilterActivity.getSecondsForDurationIndex(-1, null, expiredDate)
assert(updatedDuration != null && updatedDuration <= -expiredBySeconds)
}
@Test
fun unchangedExpiration_shouldBePositive_whenFilterIsUnexpired() {
val expiresInSeconds = 3600
val expiredDate = Date.from(Instant.now().plusSeconds(expiresInSeconds.toLong()))
val updatedDuration = EditFilterActivity.getSecondsForDurationIndex(-1, null, expiredDate)
assert(updatedDuration != null && updatedDuration > (expiresInSeconds - 60))
}
companion object {
fun mockStatus(
content: String = "",

View file

@ -3,7 +3,10 @@ package com.keylesspalace.tusky
import android.app.Activity
import android.app.NotificationManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.viewpager2.widget.ViewPager2
@ -11,13 +14,16 @@ 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.NotificationService
import com.keylesspalace.tusky.db.AccountManager
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
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getSerializableExtraCompat
import java.util.Date
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@ -68,7 +74,7 @@ class MainActivityTest {
@Test
fun `clicking notification of type FOLLOW shows notification tab`() {
val intent = showNotification(Notification.Type.FOLLOW)
val intent = showNotification(Notification.Type.Follow)
val activity = startMainActivity(intent)
val currentTab = activity.findViewById<ViewPager2>(R.id.viewPager).currentItem
@ -80,26 +86,35 @@ class MainActivityTest {
@Test
fun `clicking notification of type FOLLOW_REQUEST shows follow requests`() {
val intent = showNotification(Notification.Type.FOLLOW_REQUEST)
val intent = showNotification(Notification.Type.FollowRequest)
val activity = startMainActivity(intent)
val nextActivity = shadowOf(activity).peekNextStartedActivity()
assertNotNull(nextActivity)
assertEquals(ComponentName(context, AccountListActivity::class.java.name), nextActivity.component)
assertEquals(AccountListActivity.Type.FOLLOW_REQUESTS, nextActivity.getSerializableExtra("type"))
assertEquals(AccountListActivity.Type.FOLLOW_REQUESTS, nextActivity.getSerializableExtraCompat("type"))
}
private fun showNotification(type: Notification.Type): Intent {
val notificationManager = context.getSystemService(NotificationManager::class.java)
val shadowNotificationManager = shadowOf(notificationManager)
NotificationHelper.createNotificationChannelsForAccount(accountEntity, context)
val notificationService = NotificationService(
notificationManager,
mock {
on { areNotificationsEnabled() } doReturn true
},
mock(),
mock(),
context,
mock(),
)
notificationService.createNotificationChannelsForAccount(accountEntity)
runInBackground {
val notification = NotificationHelper.make(
context,
notificationManager,
val notification = notificationService.createBaseNotification(
Notification(
type = type,
id = "id",
@ -115,8 +130,7 @@ class MainActivityTest {
status = null,
report = null
),
accountEntity,
true
accountEntity
)
notificationManager.notify("id", 1, notification)
}
@ -128,17 +142,42 @@ class MainActivityTest {
private fun startMainActivity(intent: Intent): Activity {
val controller = Robolectric.buildActivity(MainActivity::class.java, intent)
val activity = controller.get()
activity.eventHub = EventHub()
activity.accountManager = mock {
val eventHub = EventHub()
activity.eventHub = eventHub
val accountManager: AccountManager = mock {
on { accounts } doReturn listOf(accountEntity)
on { accountsFlow } doReturn MutableStateFlow(listOf(accountEntity))
on { activeAccount } doReturn accountEntity
}
activity.draftsAlert = mock {}
activity.shareShortcutHelper = mock {}
activity.externalScope = TestScope()
activity.mastodonApi = mock {
activity.accountManager = accountManager
activity.draftsAlert = mock { }
val api: MastodonApi = mock {
onBlocking { accountVerifyCredentials() } doReturn NetworkResult.success(account)
onBlocking { listAnnouncements(false) } doReturn NetworkResult.success(emptyList())
onBlocking { announcements() } doReturn NetworkResult.success(emptyList())
}
activity.mastodonApi = api
activity.preferences = mock(defaultAnswer = {
when (it.method.returnType) {
String::class.java -> "test"
Boolean::class.java -> false
else -> null
}
})
val viewModel = MainViewModel(
context = mock {
on { getSystemService(Context.NOTIFICATION_SERVICE) } doReturn mock<NotificationManager>()
},
api = api,
eventHub = eventHub,
accountManager = accountManager,
shareShortcutHelper = mock(),
notificationService = mock(),
)
val testViewModelFactory = viewModelFactory {
initializer { viewModel }
}
activity.viewModelProviderFactory = testViewModelFactory
controller.create().start()
return activity
}

View file

@ -1,181 +1,191 @@
package com.keylesspalace.tusky
import android.text.Spannable
import com.keylesspalace.tusky.util.FoundMatchType
import com.keylesspalace.tusky.util.MENTION_PATTERN_STRING
import com.keylesspalace.tusky.util.PatternFinder
import com.keylesspalace.tusky.util.TAG_PATTERN_STRING
import com.keylesspalace.tusky.util.highlightSpans
import org.junit.Assert
import com.keylesspalace.tusky.util.twittertext.Regex
import java.util.regex.Pattern
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
class SpanUtilsTest {
@Test
fun matchesMixedSpans() {
val input = "one #one two: @two three : https://thr.ee/meh?foo=bar&wat=@at#hmm four #four five @five ろく#six"
val inputSpannable = FakeSpannable(input)
highlightSpans(inputSpannable, 0xffffff)
val spans = inputSpannable.spans
Assert.assertEquals(6, spans.size)
/** The [Pattern.UNICODE_CHARACTER_CLASS] flag is not supported on Android, on Android it is just always on.
* Since thesse tests run on a regular Jvm, we need a to set this flag or they would behave differently.
* */
private val urlPattern = Regex.VALID_URL_PATTERN_STRING.toPattern(Pattern.CASE_INSENSITIVE or Pattern.UNICODE_CHARACTER_CLASS)
private val tagPattern = TAG_PATTERN_STRING.toPattern(Pattern.CASE_INSENSITIVE or Pattern.UNICODE_CHARACTER_CLASS)
private val mentionPattern = MENTION_PATTERN_STRING.toPattern(Pattern.CASE_INSENSITIVE or Pattern.UNICODE_CHARACTER_CLASS)
val finders = listOf(
PatternFinder("http", FoundMatchType.HTTPS_URL, urlPattern),
PatternFinder("#", FoundMatchType.TAG, tagPattern),
PatternFinder("@", FoundMatchType.MENTION, mentionPattern)
)
@RunWith(Parameterized::class)
class SpanUtilsTest(
private val stringToHighlight: String,
private val highlights: List<Pair<Int, Int>>
) {
companion object {
@Parameterized.Parameters(name = "{0}")
@JvmStatic
fun data() = listOf(
arrayOf("@mention", listOf(0 to 8)),
arrayOf("@mention@server.com", listOf(0 to 19)),
arrayOf("#tag", listOf(0 to 4)),
arrayOf("#tåg", listOf(0 to 4)),
arrayOf("https://thr.ee/meh?foo=bar&wat=@at#hmm", listOf(0 to 38)),
arrayOf("http://thr.ee/meh?foo=bar&wat=@at#hmm", listOf(0 to 37)),
arrayOf(
"one #one two: @two three : https://thr.ee/meh?foo=bar&wat=@at#hmm four #four five @five 6 #six",
listOf(4 to 8, 14 to 18, 27 to 65, 71 to 76, 82 to 87, 90 to 94)
),
arrayOf("http://first.link https://second.link", listOf(0 to 17, 18 to 37)),
arrayOf("#test", listOf(0 to 5)),
arrayOf(" #AfterSpace", listOf(1 to 12)),
arrayOf("#BeforeSpace ", listOf(0 to 12)),
arrayOf("@#after_at", listOf(1 to 10)),
arrayOf("あいうえお#after_hiragana", listOf<Pair<Int, Int>>()),
arrayOf("##DoubleHash", listOf(1 to 12)),
arrayOf("###TripleHash", listOf(2 to 13)),
arrayOf("something#notAHashtag", listOf<Pair<Int, Int>>()),
arrayOf("test##maybeAHashtag", listOf(5 to 19)),
arrayOf("testhttp://not.a.url.com", listOf<Pair<Int, Int>>()),
arrayOf("test@notAMention", listOf<Pair<Int, Int>>()),
arrayOf("test@notAMention#notAHashtag", listOf<Pair<Int, Int>>()),
arrayOf("test@notAMention@server.com", listOf<Pair<Int, Int>>()),
// Mastodon will not highlight this mention, although it would be valid according to their regex
// arrayOf("@test@notAMention@server.com", listOf<Pair<Int, Int>>()),
arrayOf("testhttps://not.a.url.com", listOf<Pair<Int, Int>>()),
arrayOf("#hashtag1", listOf(0 to 9)),
arrayOf("#1hashtag", listOf(0 to 9)),
arrayOf("#サイクリング", listOf(0 to 7)),
arrayOf("#自転車に乗る", listOf(0 to 7)),
arrayOf("(#test)", listOf(1 to 6)),
arrayOf(")#test(", listOf<Pair<Int, Int>>()),
arrayOf("{#test}", listOf(1 to 6)),
arrayOf("[#test]", listOf(1 to 6)),
arrayOf("}#test{", listOf(1 to 6)),
arrayOf("]#test[", listOf(1 to 6)),
arrayOf("<#test>", listOf(1 to 6)),
arrayOf(">#test<", listOf(1 to 6)),
arrayOf("((#Test))", listOf(2 to 7)),
arrayOf("((##Te)st)", listOf(3 to 6)),
arrayOf("[@ConnyDuck]", listOf(1 to 11)),
arrayOf("(@ConnyDuck)", listOf(1 to 11)),
arrayOf("(@ConnyDuck@chaos.social)", listOf(1 to 24)),
arrayOf("Test(https://test.xyz/blubb(test)))))))))))", listOf(5 to 33)),
arrayOf("Test https://test.xyz/blubb(test)))))))))))", listOf(5 to 33)),
arrayOf("Test https://test.xyz/blubbtest)))))))))))", listOf(5 to 31)),
arrayOf("#https://test.com", listOf(0 to 6)),
arrayOf("#https://t", listOf(0 to 6)),
arrayOf("(https://blubb.com", listOf(1 to 18)),
arrayOf("https://example.com/path#anchor", listOf(0 to 31)),
arrayOf("test httpx2345://wrong.protocol.com", listOf<Pair<Int, Int>>()),
arrayOf("test https://nonexistent.topleveldomain.testtest", listOf<Pair<Int, Int>>()),
arrayOf("test https://example.com:1234 domain with port", listOf(5 to 29)),
arrayOf("http://1.1.1.1", listOf<Pair<Int, Int>>()),
arrayOf("http://foo.bar/?q=Test%20URL-encoded%20stuff", listOf(0 to 44)),
arrayOf("http://userid:password@example.com", listOf<Pair<Int, Int>>()),
arrayOf("http://userid@example.com", listOf<Pair<Int, Int>>()),
arrayOf("http://foo.com/blah_blah_(brackets)_(again)", listOf(0 to 43)),
arrayOf("test example.com/no/protocol", listOf<Pair<Int, Int>>()),
arrayOf("protocol only https://", listOf<Pair<Int, Int>>()),
arrayOf("no tld https://test", listOf<Pair<Int, Int>>()),
arrayOf("mention in url https://test.com/@test@domain.cat", listOf(15 to 48)),
arrayOf("#hash_tag", listOf(0 to 9)),
arrayOf("#hashtag_", listOf(0 to 9)),
arrayOf("#hashtag_#tag", listOf(0 to 9, 9 to 13)),
arrayOf("#hash_tag#tag", listOf(0 to 9)),
arrayOf("_#hashtag", listOf(1 to 9)),
arrayOf("@@ConnyDuck@chaos.social", listOf(1 to 24)),
arrayOf("http://https://connyduck.at", listOf(7 to 27)),
arrayOf("https://https://connyduck.at", listOf(8 to 28)),
arrayOf("http:// http://connyduck.at", listOf(8 to 27)),
arrayOf("https:// https://connyduck.at", listOf(9 to 29)),
arrayOf("https:// #test https://connyduck.at", listOf(9 to 14, 15 to 35)),
arrayOf("http:// @connyduck http://connyduck.at", listOf(8 to 18, 19 to 38)),
// emojis count as multiple characters
arrayOf("😜https://connyduck.at", listOf(2 to 22)),
arrayOf("😜#tag", listOf(2 to 6)),
arrayOf("😜@user@mastodon.example", listOf(2 to 24)),
// case should be ignored on protocols https://github.com/tuskyapp/Tusky/issues/4641
arrayOf("HTTPS://example.com", listOf(0 to 19)),
arrayOf("Http://example.com/test", listOf(0 to 23)),
arrayOf("test Https://example.com/test", listOf(5 to 29)),
)
}
@Test
fun doesntMergeAdjacentURLs() {
val firstURL = "http://first.thing"
val secondURL = "https://second.thing"
val inputSpannable = FakeSpannable("$firstURL $secondURL")
highlightSpans(inputSpannable, 0xffffff)
val spans = inputSpannable.spans
Assert.assertEquals(2, spans.size)
Assert.assertEquals(firstURL.length, spans[0].end - spans[0].start)
Assert.assertEquals(secondURL.length, spans[1].end - spans[1].start)
}
fun testHighlighting() {
val inputSpannable = FakeSpannable(stringToHighlight)
inputSpannable.highlightSpans(0xffffff, finders)
@RunWith(Parameterized::class)
class MatchingTests(private val thingToHighlight: String) {
companion object {
@Parameterized.Parameters(name = "{0}")
@JvmStatic
fun data(): Iterable<Any> {
return listOf(
"@mention",
"#tag",
"#tåg",
"https://thr.ee/meh?foo=bar&wat=@at#hmm",
"http://thr.ee/meh?foo=bar&wat=@at#hmm"
)
assertEquals(highlights.size, inputSpannable.spans.size)
inputSpannable.spans
.sortedBy { span -> span.start }
.forEachIndexed { index, span ->
assertEquals(highlights[index].first, span.start)
assertEquals(highlights[index].second, span.end)
}
}
@Test
fun matchesSpanAtStart() {
val inputSpannable = FakeSpannable(thingToHighlight)
highlightSpans(inputSpannable, 0xffffff)
val spans = inputSpannable.spans
Assert.assertEquals(1, spans.size)
Assert.assertEquals(thingToHighlight.length, spans[0].end - spans[0].start)
}
@Test
fun matchesSpanNotAtStart() {
val inputSpannable = FakeSpannable(" $thingToHighlight")
highlightSpans(inputSpannable, 0xffffff)
val spans = inputSpannable.spans
Assert.assertEquals(1, spans.size)
Assert.assertEquals(thingToHighlight.length, spans[0].end - spans[0].start)
}
@Test
fun doesNotMatchSpanEmbeddedInText() {
val inputSpannable = FakeSpannable("aa${thingToHighlight}aa")
highlightSpans(inputSpannable, 0xffffff)
val spans = inputSpannable.spans
Assert.assertTrue(spans.isEmpty())
}
@Test
fun doesNotMatchSpanEmbeddedInAnotherSpan() {
val inputSpannable = FakeSpannable("@aa${thingToHighlight}aa")
highlightSpans(inputSpannable, 0xffffff)
val spans = inputSpannable.spans
Assert.assertEquals(1, spans.size)
}
@Test
fun spansDoNotOverlap() {
val begin = "@begin"
val end = "#end"
val inputSpannable = FakeSpannable("$begin $thingToHighlight $end")
highlightSpans(inputSpannable, 0xffffff)
val spans = inputSpannable.spans
Assert.assertEquals(3, spans.size)
val middleSpan = spans.single { span -> span.start > 0 && span.end < inputSpannable.lastIndex }
Assert.assertEquals(begin.length + 1, middleSpan.start)
Assert.assertEquals(inputSpannable.length - end.length - 1, middleSpan.end)
}
}
@RunWith(Parameterized::class)
class HighlightingTestsForTag(
private val text: String,
private val expectedStartIndex: Int,
private val expectedEndIndex: Int
) {
companion object {
@Parameterized.Parameters(name = "{0}")
@JvmStatic
fun data(): Iterable<Any> {
return listOf(
arrayOf("#test", 0, 5),
arrayOf(" #AfterSpace", 1, 12),
arrayOf("#BeforeSpace ", 0, 12),
arrayOf("@#after_at", 1, 10),
arrayOf("あいうえお#after_hiragana", 5, 20),
arrayOf("##DoubleHash", 1, 12),
arrayOf("###TripleHash", 2, 13)
)
}
}
@Test
fun matchExpectations() {
val inputSpannable = FakeSpannable(text)
highlightSpans(inputSpannable, 0xffffff)
val spans = inputSpannable.spans
Assert.assertEquals(1, spans.size)
val span = spans.first()
Assert.assertEquals(expectedStartIndex, span.start)
Assert.assertEquals(expectedEndIndex, span.end)
}
}
class FakeSpannable(private val text: String) : Spannable {
val spans = mutableListOf<BoundedSpan>()
override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) {
spans.add(BoundedSpan(what, start, end))
}
override fun <T : Any> getSpans(start: Int, end: Int, type: Class<T>): Array<T> {
return spans.filter { it.start >= start && it.end <= end && type.isInstance(it.span) }
.map { it.span }
.toTypedArray() as Array<T>
}
override fun removeSpan(what: Any?) {
spans.removeIf { span -> span.span == what }
}
override fun toString(): String {
return text
}
override val length: Int
get() = text.length
class BoundedSpan(val span: Any?, val start: Int, val end: Int)
override fun nextSpanTransition(start: Int, limit: Int, type: Class<*>?): Int {
throw NotImplementedError()
}
override fun getSpanEnd(tag: Any?): Int {
throw NotImplementedError()
}
override fun getSpanFlags(tag: Any?): Int {
throw NotImplementedError()
}
override fun get(index: Int): Char {
throw NotImplementedError()
}
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence {
throw NotImplementedError()
}
override fun getSpanStart(tag: Any?): Int {
throw NotImplementedError()
}
}
}
class FakeSpannable(private val text: String) : Spannable {
val spans = mutableListOf<BoundedSpan>()
override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) {
spans.add(BoundedSpan(what, start, end))
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getSpans(start: Int, end: Int, type: Class<T>): Array<T> {
return spans.filter { it.start >= start && it.end <= end && type.isInstance(it.span) }
.map { it.span }
.toTypedArray() as Array<T>
}
override fun removeSpan(what: Any?) {
spans.removeIf { span -> span.span == what }
}
override fun toString(): String {
return text
}
override val length: Int
get() = text.length
class BoundedSpan(val span: Any?, val start: Int, val end: Int)
override fun nextSpanTransition(start: Int, limit: Int, type: Class<*>?): Int {
throw NotImplementedError()
}
override fun getSpanEnd(tag: Any?): Int {
throw NotImplementedError()
}
override fun getSpanFlags(tag: Any?): Int {
throw NotImplementedError()
}
override fun get(index: Int): Char {
return text[index]
}
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence {
return text.subSequence(startIndex, endIndex)
}
override fun getSpanStart(tag: Any?): Int {
throw NotImplementedError()
}
}

View file

@ -11,7 +11,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@Config(sdk = [28])
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class StatusComparisonTest {

View file

@ -16,14 +16,20 @@
package com.keylesspalace.tusky
import android.app.Application
import android.content.SharedPreferences
import com.keylesspalace.tusky.di.PreferencesEntryPoint
import dagger.hilt.internal.GeneratedComponent
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper
import org.mockito.kotlin.mock
// override TuskyApplication for Robolectric tests, only initialize the necessary stuff
class TuskyApplication : Application() {
class TuskyApplication : Application(), PreferencesEntryPoint, GeneratedComponent {
override fun onCreate() {
super.onCreate()
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this))
}
override fun preferences(): SharedPreferences = mock {}
}

View file

@ -20,28 +20,31 @@ package com.keylesspalace.tusky.components.compose
import android.content.Intent
import android.os.Looper.getMainLooper
import android.widget.EditText
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
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
import com.keylesspalace.tusky.entity.InstanceConfiguration
import com.keylesspalace.tusky.entity.InstanceV1
import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.entity.StatusConfiguration
import com.keylesspalace.tusky.finders
import com.keylesspalace.tusky.network.MastodonApi
import com.squareup.moshi.adapter
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
@ -53,7 +56,6 @@ import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.robolectric.Robolectric
import org.robolectric.Shadows.shadowOf
@ -66,7 +68,7 @@ import retrofit2.Response
* Created by charlag on 3/7/18.
*/
@Config(sdk = [28])
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class ComposeActivityTest {
private lateinit var activity: ComposeActivity
@ -107,6 +109,8 @@ class ComposeActivityTest {
activity = controller.get()
accountManagerMock = mock {
on { accounts } doReturn listOf(account)
on { accountsFlow } doReturn MutableStateFlow(listOf(account))
on { activeAccount } doReturn account
}
@ -156,12 +160,22 @@ class ComposeActivityTest {
putExtra(ComposeActivity.COMPOSE_OPTIONS_EXTRA, composeOptions)
}
val viewModelFactoryMock: ViewModelFactory = mock {
on { create(eq(ComposeViewModel::class.java), any()) } doReturn viewModel
val testViewModelFactory = viewModelFactory {
initializer { viewModel }
}
activity.accountManager = accountManagerMock
activity.viewModelFactory = viewModelFactoryMock
activity.viewModelProviderFactory = testViewModelFactory
activity.preferences = mock(defaultAnswer = {
when (it.method.returnType) {
String::class.java -> "test"
Boolean::class.java -> false
else -> null
}
})
activity.highlightFinders = finders
controller.create().start()
shadowOf(getMainLooper()).idle()
@ -277,7 +291,7 @@ class ComposeActivityTest {
@Test
fun whenTextContainsUrl_onlyEllipsizedURLIsCounted() {
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM"
val additionalContent = "Check out this @image #search result: "
insertSomeTextInContent(additionalContent + url)
assertEquals(additionalContent.length + InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL, activity.calculateTextLength())
@ -286,7 +300,7 @@ class ComposeActivityTest {
@Test
fun whenTextContainsShortUrls_allUrlsGetEllipsized() {
val shortUrl = "https://tusky.app"
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM"
val additionalContent = " Check out this @image #search result: "
insertSomeTextInContent(shortUrl + additionalContent + url)
assertEquals(additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2), activity.calculateTextLength())
@ -294,7 +308,7 @@ class ComposeActivityTest {
@Test
fun whenTextContainsMultipleURLs_allURLsGetEllipsized() {
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM"
val additionalContent = " Check out this @image #search result: "
insertSomeTextInContent(url + additionalContent + url)
assertEquals(additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2), activity.calculateTextLength())
@ -302,7 +316,7 @@ class ComposeActivityTest {
@Test
fun whenTextContainsUrl_onlyEllipsizedURLIsCounted_withCustomConfiguration() {
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM"
val additionalContent = "Check out this @image #search result: "
val customUrlLength = 16
instanceResponseCallback = { getInstanceWithCustomConfiguration(null, customUrlLength) }
@ -314,7 +328,7 @@ class ComposeActivityTest {
@Test
fun whenTextContainsUrl_onlyEllipsizedURLIsCounted_withCustomConfigurationV1() {
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM"
val additionalContent = "Check out this @image #search result: "
val customUrlLength = 16
instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(configuration = getCustomInstanceConfiguration(charactersReservedPerUrl = customUrlLength)) }
@ -327,7 +341,7 @@ class ComposeActivityTest {
@Test
fun whenTextContainsShortUrls_allUrlsGetEllipsized_withCustomConfiguration() {
val shortUrl = "https://tusky.app"
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM"
val additionalContent = " Check out this @image #search result: "
val customUrlLength = 18 // The intention is that this is longer than shortUrl.length
instanceResponseCallback = { getInstanceWithCustomConfiguration(null, customUrlLength) }
@ -340,7 +354,7 @@ class ComposeActivityTest {
@Test
fun whenTextContainsShortUrls_allUrlsGetEllipsized_withCustomConfigurationV1() {
val shortUrl = "https://tusky.app"
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM"
val additionalContent = " Check out this @image #search result: "
val customUrlLength = 18 // The intention is that this is longer than shortUrl.length
instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(configuration = getCustomInstanceConfiguration(charactersReservedPerUrl = customUrlLength)) }
@ -352,7 +366,7 @@ class ComposeActivityTest {
@Test
fun whenTextContainsMultipleURLs_allURLsGetEllipsized_withCustomConfiguration() {
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM"
val additionalContent = " Check out this @image #search result: "
val customUrlLength = 16
instanceResponseCallback = { getInstanceWithCustomConfiguration(null, customUrlLength) }
@ -364,7 +378,7 @@ class ComposeActivityTest {
@Test
fun whenTextContainsMultipleURLs_allURLsGetEllipsized_withCustomConfigurationV1() {
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM"
val additionalContent = " Check out this @image #search result: "
val customUrlLength = 16
instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(configuration = getCustomInstanceConfiguration(charactersReservedPerUrl = customUrlLength)) }
@ -585,7 +599,7 @@ class ComposeActivityTest {
private fun getConfiguration(maximumStatusCharacters: Int?, charactersReservedPerUrl: Int?): Instance.Configuration {
return Instance.Configuration(
Instance.Configuration.Urls(),
Instance.Configuration.Accounts(1),
Instance.Configuration.Accounts(maxFeaturedTags = 1, maxProfileFields = 4),
Instance.Configuration.Statuses(
maximumStatusCharacters ?: InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT,
InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS,

View file

@ -15,7 +15,7 @@
package com.keylesspalace.tusky.components.compose
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@ -30,7 +30,7 @@ class ComposeTokenizerTest(
companion object {
@Parameterized.Parameters(name = "{0}")
@JvmStatic
fun data(): Iterable<Any> {
fun data(): Iterable<Array<Any>> {
return listOf(
arrayOf("@mention", 0, 8),
arrayOf("@ment10n", 0, 8),
@ -89,7 +89,7 @@ class ComposeTokenizerTest(
@Test
fun tokenIndices_matchExpectations() {
Assert.assertEquals(expectedStartIndex, tokenizer.findTokenStart(text, text.length))
Assert.assertEquals(expectedEndIndex, tokenizer.findTokenEnd(text, text.length))
assertEquals(expectedStartIndex, tokenizer.findTokenStart(text, text.length))
assertEquals(expectedEndIndex, tokenizer.findTokenEnd(text, text.length))
}
}

View file

@ -0,0 +1,75 @@
package com.keylesspalace.tusky.components.compose
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.DefaultReplyVisibility
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.robolectric.annotation.Config
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class ComposeViewModelTest {
private lateinit var api: MastodonApi
private lateinit var accountManager: AccountManager
private lateinit var eventHub: EventHub
private lateinit var viewModel: ComposeViewModel
private fun setup(defaultReplyVisibility: DefaultReplyVisibility = DefaultReplyVisibility.UNLISTED) {
api = mock()
accountManager = mock {
on { activeAccount } doReturn
AccountEntity(
id = 1,
domain = "test.domain",
accessToken = "fakeToken",
clientId = "fakeId",
clientSecret = "fakeSecret",
isActive = true,
defaultReplyPrivacy = defaultReplyVisibility
)
}
eventHub = EventHub()
viewModel = ComposeViewModel(
api = api,
accountManager = accountManager,
mediaUploader = mock(),
serviceClient = mock(),
draftHelper = mock(),
instanceInfoRepo = mock(),
)
}
@Test
fun `startingVisibility initially set to defaultPostPrivacy for post`() {
setup()
viewModel.setup(null)
assertEquals(Status.Visibility.PUBLIC, viewModel.statusVisibility.value)
}
@Test
fun `startingVisibility initially set to replyPostPrivacy for reply`() {
setup()
viewModel.setup(ComposeActivity.ComposeOptions(inReplyToId = "123"))
assertEquals(Status.Visibility.UNLISTED, viewModel.statusVisibility.value)
}
@Test
fun `startingVisibility initially set to defaultPostPrivacy when replyPostPrivacy is MATCH_DEFAULT_POST_VISIBILITY for reply`() {
setup(defaultReplyVisibility = DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY)
viewModel.setup(ComposeActivity.ComposeOptions(inReplyToId = "123"))
assertEquals(Status.Visibility.PUBLIC, viewModel.statusVisibility.value)
}
}

View file

@ -17,7 +17,8 @@
package com.keylesspalace.tusky.components.compose
import com.keylesspalace.tusky.SpanUtilsTest
import com.keylesspalace.tusky.FakeSpannable
import com.keylesspalace.tusky.finders
import com.keylesspalace.tusky.util.highlightSpans
import org.junit.Assert.assertEquals
import org.junit.Test
@ -26,7 +27,7 @@ import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(ParameterizedRobolectricTestRunner::class)
@Config(sdk = [33])
@Config(sdk = [34])
class StatusLengthTest(
private val text: String,
private val expectedLength: Int
@ -34,7 +35,7 @@ class StatusLengthTest(
companion object {
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
@JvmStatic
fun data(): Iterable<Any> {
fun data(): Iterable<Array<Any>> {
return listOf(
arrayOf("", 0),
arrayOf(" ", 1),
@ -42,22 +43,28 @@ class StatusLengthTest(
arrayOf("🫣", 1),
// "@user@server" should be treated as "@user"
arrayOf("123 @example@example.org", 12),
// URLs under 23 chars are treated as 23 chars
arrayOf("123 http://example.url", 27),
// URLs are always treated as 23 even if they are shorter
arrayOf("123 http://example.org", 27),
// URLs over 23 chars are treated as 23 chars
arrayOf("123 http://urlthatislongerthan23characters.example.org", 27),
// Short hashtags are treated as is
arrayOf("123 #basictag", 13),
// Long hashtags are *also* treated as is (not treated as 23, like URLs)
arrayOf("123 #atagthatislongerthan23characters", 37)
arrayOf("123 #atagthatislongerthan23characters", 37),
// urls can have balanced parenthesis, otherwise they are ignored https://github.com/tuskyapp/Tusky/issues/4425
arrayOf("(https://en.wikipedia.org/wiki/Beethoven_(horse))", 25),
// protocols can have any case https://github.com/tuskyapp/Tusky/issues/4641
arrayOf("Http://example.org", 23),
arrayOf("HTTPS://example.org", 23),
arrayOf("HTTPS://EXAMPLE.ORG", 23)
)
}
}
@Test
fun statusLength_matchesExpectations() {
val spannedText = SpanUtilsTest.FakeSpannable(text)
highlightSpans(spannedText, 0)
val spannedText = FakeSpannable(text)
spannedText.highlightSpans(0, finders)
assertEquals(
expectedLength,
@ -67,10 +74,10 @@ class StatusLengthTest(
@Test
fun statusLength_withCwText_matchesExpectations() {
val spannedText = SpanUtilsTest.FakeSpannable(text)
highlightSpans(spannedText, 0)
val spannedText = FakeSpannable(text)
spannedText.highlightSpans(0, finders)
val cwText = SpanUtilsTest.FakeSpannable(
val cwText = FakeSpannable(
"a @example@example.org #hashtagmention and http://example.org URL"
)
assertEquals(

View file

@ -0,0 +1,141 @@
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),
event = null,
moderationWarning = null,
)
fun Placeholder.toNotificationDataEntity(
tuskyAccountId: Long
) = NotificationDataEntity(
tuskyAccountId = tuskyAccountId,
type = null,
id = id,
account = null,
status = null,
statusAccount = null,
report = null,
reportTargetAccount = null,
event = null,
moderationWarning = 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,
event = null,
moderationWarning = null,
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,572 @@
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.components.timeline.fakeStatus
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.flow.MutableStateFlow
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 = [34])
@RunWith(AndroidJUnit4::class)
class NotificationsRemoteMediatorTest {
private val account = AccountEntity(
id = 1,
domain = "mastodon.example",
accessToken = "token",
clientId = "id",
clientSecret = "secret",
isActive = true
)
private val accountManager: AccountManager = mock {
on { activeAccount } doReturn account
on { accountsFlow } doReturn MutableStateFlow(listOf(account))
}
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(
viewModel = mockViewModel(),
accountManager = accountManager,
api = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody())
},
db = db
)
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(
viewModel = mockViewModel(),
accountManager = accountManager,
api = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException()
},
db = db
)
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(
viewModel = mockViewModel(),
accountManager = accountManager,
api = mock(),
db = db
)
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(
viewModel = mockViewModel(),
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
)
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(
viewModel = mockViewModel(),
accountManager = accountManager,
api = mock {
onBlocking { notifications(limit = 20, excludes = emptySet()) } doReturn Response.success(
listOf(
// testing for https://github.com/tuskyapp/Tusky/issues/4563
fakeNotification(
id = "8",
status = fakeStatus(
id = "r1",
reblog = fakeStatus(
id = "8",
authorServerId = "r1"
)
)
),
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
)
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",
status = fakeStatus(
id = "8",
authorServerId = "r1"
)
).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(
viewModel = mockViewModel(),
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
)
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(
viewModel = mockViewModel(),
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
)
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(
viewModel = mockViewModel(),
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
)
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(
viewModel = mockViewModel(),
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
)
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(
viewModel = mockViewModel(),
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
)
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
)
private fun mockViewModel(): NotificationsViewModel {
return mock {
on { activeAccountFlow } doReturn MutableStateFlow(account)
on { excludes } doReturn MutableStateFlow(emptySet())
}
}
}

View file

@ -11,15 +11,17 @@ 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.components.timeline.viewmodel.CachedTimelineViewModel
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.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
@ -37,21 +39,10 @@ import org.robolectric.annotation.Config
import retrofit2.HttpException
import retrofit2.Response
@Config(sdk = [28])
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class CachedTimelineRemoteMediatorTest {
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()
@ -75,17 +66,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,
viewModel = mockViewModel(),
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 +84,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,
viewModel = mockViewModel(),
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 +101,18 @@ class CachedTimelineRemoteMediatorTest {
@Test
@ExperimentalPagingApi
fun `should not prepend statuses`() {
fun `should not prepend statuses`() = runTest {
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
viewModel = mockViewModel(),
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 +120,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,35 +128,34 @@ 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)
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
viewModel = mockViewModel(),
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,56 +169,53 @@ 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)
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
viewModel = mockViewModel(),
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,54 +228,53 @@ 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)
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
viewModel = mockViewModel(),
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 +288,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,
viewModel = mockViewModel(),
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,45 +331,44 @@ 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)
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
viewModel = mockViewModel(),
api = mock {
onBlocking { homeTimeline(limit = 20) } doReturn Response.success(emptyList())
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,50 +381,49 @@ 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)
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
viewModel = mockViewModel(),
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,46 +436,45 @@ 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)
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
viewModel = mockViewModel(),
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 +487,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,
@ -536,40 +515,22 @@ 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 mockViewModel(): CachedTimelineViewModel {
val account = AccountEntity(
id = 1,
domain = "mastodon.example",
accessToken = "token",
clientId = "id",
clientSecret = "secret",
isActive = true
)
val accManager: AccountManager = mock {
on { activeAccount } doReturn account
on { accountsFlow } doReturn MutableStateFlow(listOf(account))
}
}
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)
}
return mock {
on { accountManager } doReturn accManager
on { activeAccountFlow } doReturn MutableStateFlow(account)
}
}
}

View file

@ -4,7 +4,7 @@ import androidx.paging.PagingSource
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@ -12,53 +12,47 @@ import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.robolectric.annotation.Config
@Config(sdk = [28])
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class NetworkTimelinePagingSourceTest {
private val status = mockStatusViewData()
private val status = fakeStatusViewData()
private val timelineViewModel: NetworkTimelineViewModel = mock {
on { statusData } doReturn mutableListOf(status)
}
@Test
fun `should return empty list when params are Append`() {
fun `should return empty list when params are Append`() = runTest {
val pagingSource = NetworkTimelinePagingSource(timelineViewModel)
val params = PagingSource.LoadParams.Append("132", 20, false)
val expectedResult = PagingSource.LoadResult.Page(emptyList(), null, null)
runBlocking {
assertEquals(expectedResult, pagingSource.load(params))
}
assertEquals(expectedResult, pagingSource.load(params))
}
@Test
fun `should return empty list when params are Prepend`() {
fun `should return empty list when params are Prepend`() = runTest {
val pagingSource = NetworkTimelinePagingSource(timelineViewModel)
val params = PagingSource.LoadParams.Prepend("132", 20, false)
val expectedResult = PagingSource.LoadResult.Page(emptyList(), null, null)
runBlocking {
assertEquals(expectedResult, pagingSource.load(params))
}
assertEquals(expectedResult, pagingSource.load(params))
}
@Test
fun `should return full list when params are Refresh`() {
fun `should return full list when params are Refresh`() = runTest {
val pagingSource = NetworkTimelinePagingSource(timelineViewModel)
val params = PagingSource.LoadParams.Refresh<String>(null, 20, false)
val expectedResult = PagingSource.LoadResult.Page(listOf(status), null, null)
runBlocking {
val result = pagingSource.load(params)
assertEquals(expectedResult, result)
}
val result = pagingSource.load(params)
assertEquals(expectedResult, result)
}
}

View file

@ -10,11 +10,12 @@ 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
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import okhttp3.Headers
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
@ -30,32 +31,34 @@ import org.robolectric.annotation.Config
import retrofit2.HttpException
import retrofit2.Response
@Config(sdk = [29])
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class NetworkTimelineRemoteMediatorTest {
private val account = AccountEntity(
id = 1,
domain = "mastodon.example",
accessToken = "token",
clientId = "id",
clientSecret = "secret",
isActive = true
)
private val accountManager: AccountManager = mock {
on { activeAccount } doReturn AccountEntity(
id = 1,
domain = "mastodon.example",
accessToken = "token",
clientId = "id",
clientSecret = "secret",
isActive = true
)
on { activeAccount } doReturn account
}
@Test
@ExperimentalPagingApi
fun `should return error when network call returns error code`() {
fun `should return error when network call returns error code`() = runTest {
val timelineViewModel: NetworkTimelineViewModel = mock {
on { statusData } doReturn mutableListOf()
onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody())
}
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel)
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)
@ -64,15 +67,17 @@ class NetworkTimelineRemoteMediatorTest {
@Test
@ExperimentalPagingApi
fun `should return error when network call fails`() {
fun `should return error when network call fails`() = runTest {
val timelineViewModel: NetworkTimelineViewModel = mock {
on { accountManager } doReturn accountManager
on { activeAccountFlow } doReturn MutableStateFlow(account)
on { statusData } doReturn mutableListOf()
onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException()
}
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel)
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)
@ -80,17 +85,20 @@ class NetworkTimelineRemoteMediatorTest {
@Test
@ExperimentalPagingApi
fun `should do initial loading`() {
fun `should do initial loading`() = runTest {
val statuses: MutableList<StatusViewData> = mutableListOf()
val timelineViewModel: NetworkTimelineViewModel = mock {
on { accountManager } doReturn accountManager
on { activeAccountFlow } doReturn MutableStateFlow(account)
on { activeAccountFlow } doReturn MutableStateFlow(account)
on { statusData } doReturn statuses
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",
@ -99,7 +107,7 @@ class NetworkTimelineRemoteMediatorTest {
)
}
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel)
val state = state(
listOf(
@ -111,12 +119,12 @@ class NetworkTimelineRemoteMediatorTest {
)
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
val result = remoteMediator.load(LoadType.REFRESH, state)
val newStatusData = mutableListOf(
mockStatusViewData("7"),
mockStatusViewData("6"),
mockStatusViewData("5")
fakeStatusViewData("7"),
fakeStatusViewData("6"),
fakeStatusViewData("5")
)
verify(timelineViewModel).nextKey = "4"
@ -127,34 +135,36 @@ class NetworkTimelineRemoteMediatorTest {
@Test
@ExperimentalPagingApi
fun `should not prepend statuses`() {
fun `should not prepend statuses`() = runTest {
val statuses: MutableList<StatusViewData> = mutableListOf(
mockStatusViewData("3"),
mockStatusViewData("2"),
mockStatusViewData("1")
fakeStatusViewData("3"),
fakeStatusViewData("2"),
fakeStatusViewData("1")
)
val timelineViewModel: NetworkTimelineViewModel = mock {
on { accountManager } doReturn accountManager
on { activeAccountFlow } doReturn MutableStateFlow(account)
on { statusData } doReturn statuses
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")
)
)
}
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = listOf(
mockStatusViewData("3"),
mockStatusViewData("2"),
mockStatusViewData("1")
fakeStatusViewData("3"),
fakeStatusViewData("2"),
fakeStatusViewData("1")
),
prevKey = null,
nextKey = "0"
@ -162,14 +172,14 @@ class NetworkTimelineRemoteMediatorTest {
)
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
val result = 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)
@ -179,34 +189,36 @@ class NetworkTimelineRemoteMediatorTest {
@Test
@ExperimentalPagingApi
fun `should refresh and insert placeholder`() {
fun `should refresh and insert placeholder`() = runTest {
val statuses: MutableList<StatusViewData> = mutableListOf(
mockStatusViewData("3"),
mockStatusViewData("2"),
mockStatusViewData("1")
fakeStatusViewData("3"),
fakeStatusViewData("2"),
fakeStatusViewData("1")
)
val timelineViewModel: NetworkTimelineViewModel = mock {
on { accountManager } doReturn accountManager
on { activeAccountFlow } doReturn MutableStateFlow(account)
on { statusData } doReturn statuses
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")
)
)
}
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = listOf(
mockStatusViewData("3"),
mockStatusViewData("2"),
mockStatusViewData("1")
fakeStatusViewData("3"),
fakeStatusViewData("2"),
fakeStatusViewData("1")
),
prevKey = null,
nextKey = "0"
@ -214,15 +226,15 @@ class NetworkTimelineRemoteMediatorTest {
)
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
val result = 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)
@ -232,34 +244,36 @@ class NetworkTimelineRemoteMediatorTest {
@Test
@ExperimentalPagingApi
fun `should refresh and not insert placeholders`() {
fun `should refresh and not insert placeholders`() = runTest {
val statuses: MutableList<StatusViewData> = mutableListOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5")
)
val timelineViewModel: NetworkTimelineViewModel = mock {
on { accountManager } doReturn accountManager
on { activeAccountFlow } doReturn MutableStateFlow(account)
on { statusData } doReturn statuses
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")
)
)
}
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = listOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5")
),
prevKey = null,
nextKey = "3"
@ -267,15 +281,15 @@ class NetworkTimelineRemoteMediatorTest {
)
)
val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
val result = 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)
@ -285,21 +299,23 @@ class NetworkTimelineRemoteMediatorTest {
@Test
@ExperimentalPagingApi
fun `should append statuses`() {
fun `should append statuses`() = runTest {
val statuses: MutableList<StatusViewData> = mutableListOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5")
)
val timelineViewModel: NetworkTimelineViewModel = mock {
on { accountManager } doReturn accountManager
on { activeAccountFlow } doReturn MutableStateFlow(account)
on { statusData } doReturn statuses
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",
@ -308,15 +324,15 @@ class NetworkTimelineRemoteMediatorTest {
)
}
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = listOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5")
),
prevKey = null,
nextKey = "3"
@ -324,15 +340,15 @@ class NetworkTimelineRemoteMediatorTest {
)
)
val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
val result = 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)
@ -342,27 +358,29 @@ class NetworkTimelineRemoteMediatorTest {
@Test
@ExperimentalPagingApi
fun `should not append statuses when pagination end has been reached`() {
fun `should not append statuses when pagination end has been reached`() = runTest {
val statuses: MutableList<StatusViewData> = mutableListOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5")
)
val timelineViewModel: NetworkTimelineViewModel = mock {
on { accountManager } doReturn accountManager
on { activeAccountFlow } doReturn MutableStateFlow(account)
on { statusData } doReturn statuses
on { nextKey } doReturn null
}
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = listOf(
mockStatusViewData("8"),
mockStatusViewData("7"),
mockStatusViewData("5")
fakeStatusViewData("8"),
fakeStatusViewData("7"),
fakeStatusViewData("5")
),
prevKey = null,
nextKey = null
@ -370,12 +388,12 @@ class NetworkTimelineRemoteMediatorTest {
)
)
val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
val result = 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)
@ -385,22 +403,24 @@ class NetworkTimelineRemoteMediatorTest {
@Test
@ExperimentalPagingApi
fun `should not append duplicates for trending statuses`() {
fun `should not append duplicates for trending statuses`() = runTest {
val statuses: MutableList<StatusViewData> = mutableListOf(
mockStatusViewData("5"),
mockStatusViewData("4"),
mockStatusViewData("3")
fakeStatusViewData("5"),
fakeStatusViewData("4"),
fakeStatusViewData("3")
)
val timelineViewModel: NetworkTimelineViewModel = mock {
on { accountManager } doReturn accountManager
on { activeAccountFlow } doReturn MutableStateFlow(account)
on { statusData } doReturn statuses
on { nextKey } doReturn "3"
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",
@ -409,7 +429,7 @@ class NetworkTimelineRemoteMediatorTest {
)
}
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel)
val state = state(
listOf(
@ -421,14 +441,14 @@ class NetworkTimelineRemoteMediatorTest {
)
)
val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
val result = 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,192 @@
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",
reblog: Status? = null
) = Status(
id = id,
url = "https://$domain/@ConnyDuck/$id",
account = fakeAccount(
id = authorServerId,
domain = domain
),
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
reblog = reblog,
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,
)
},
repliedToAccount = null,
loading = false
)
}
fun fakePlaceholderHomeTimelineData(
id: String
) = HomeTimelineData(
id = id,
account = null,
status = null,
reblogAccount = null,
repliedToAccount = 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,13 @@ 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.instanceinfo.InstanceInfoRepository
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
@ -21,7 +22,7 @@ import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.usecase.TimelineCases
import java.io.IOException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
@ -34,7 +35,7 @@ import org.mockito.kotlin.stub
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@Config(sdk = [28])
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class ViewThreadViewModelTest {
@ -81,8 +82,11 @@ class ViewThreadViewModelTest {
api = mock {
onBlocking { getFilters() } doReturn NetworkResult.success(emptyList())
}
val instanceInfoRepo: InstanceInfoRepository = mock {
onBlocking { isFilterV2Supported() } doReturn false
}
eventHub = EventHub()
val filterModel = FilterModel()
val filterModel = FilterModel(instanceInfoRepo, api)
val timelineCases = TimelineCases(api, eventHub)
val accountManager: AccountManager = mock {
on { activeAccount } doReturn AccountEntity(
@ -109,52 +113,48 @@ class ViewThreadViewModelTest {
}
@Test
fun `should emit status and context when both load`() {
fun `should emit status and context when both load`() = runTest {
mockSuccessResponses()
viewModel.loadThread(threadId)
runBlocking {
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")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
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")
),
viewModel.uiState.first()
)
}
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
),
viewModel.uiState.first()
)
}
@Test
fun `should emit status even if context fails to load`() {
fun `should emit status even if context fails to load`() = runTest {
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())
}
viewModel.loadThread(threadId)
runBlocking {
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true)
),
detailedStatusPosition = 0,
revealButton = RevealButtonState.NO_BUTTON
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
fakeStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true)
),
viewModel.uiState.first()
)
}
detailedStatusPosition = 0,
revealButton = RevealButtonState.NO_BUTTON
),
viewModel.uiState.first()
)
}
@Test
fun `should emit error when status and context fail to load`() {
fun `should emit error when status and context fail to load`() = runTest {
api.stub {
onBlocking { status(threadId) } doReturn NetworkResult.failure(IOException())
onBlocking { statusContext(threadId) } doReturn NetworkResult.failure(IOException())
@ -162,194 +162,178 @@ class ViewThreadViewModelTest {
viewModel.loadThread(threadId)
runBlocking {
assertEquals(
ThreadUiState.Error::class.java,
viewModel.uiState.first().javaClass
)
}
assertEquals(
ThreadUiState.Error::class.java,
viewModel.uiState.first().javaClass
)
}
@Test
fun `should emit error when status fails to load`() {
fun `should emit error when status fails to load`() = runTest {
api.stub {
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"))
)
)
}
viewModel.loadThread(threadId)
runBlocking {
assertEquals(
ThreadUiState.Error::class.java,
viewModel.uiState.first().javaClass
)
}
assertEquals(
ThreadUiState.Error::class.java,
viewModel.uiState.first().javaClass
)
}
@Test
fun `should update state when reveal button is toggled`() {
fun `should update state when reveal button is toggled`() = runTest {
mockSuccessResponses()
viewModel.loadThread(threadId)
viewModel.toggleRevealButton()
runBlocking {
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)
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.HIDE
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
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)
),
viewModel.uiState.first()
)
}
detailedStatusPosition = 1,
revealButton = RevealButtonState.HIDE
),
viewModel.uiState.first()
)
}
@Test
fun `should handle status changed event`() {
fun `should handle status changed event`() = runTest {
mockSuccessResponses()
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")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
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")
),
viewModel.uiState.first()
)
}
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
),
viewModel.uiState.first()
)
}
@Test
fun `should remove status`() {
fun `should remove status`() = runTest {
mockSuccessResponses()
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")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
fakeStatusViewData(id = "1", spoilerText = "Test"),
fakeStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
),
viewModel.uiState.first()
)
}
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
),
viewModel.uiState.first()
)
}
@Test
fun `should change status expanded state`() {
fun `should change status expanded state`() = runTest {
mockSuccessResponses()
viewModel.loadThread(threadId)
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")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
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")
),
viewModel.uiState.first()
)
}
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
),
viewModel.uiState.first()
)
}
@Test
fun `should change content collapsed state`() {
fun `should change content collapsed state`() = runTest {
mockSuccessResponses()
viewModel.loadThread(threadId)
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")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
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")
),
viewModel.uiState.first()
)
}
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
),
viewModel.uiState.first()
)
}
@Test
fun `should change content showing state`() {
fun `should change content showing state`() = runTest {
mockSuccessResponses()
viewModel.loadThread(threadId)
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")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
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")
),
viewModel.uiState.first()
)
}
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
),
viewModel.uiState.first()
)
}
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

@ -0,0 +1,89 @@
package com.keylesspalace.tusky.db
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.keylesspalace.tusky.di.StorageModule
import com.keylesspalace.tusky.entity.Emoji
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class MigrationsTest {
@get:Rule
val migrationHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
@Test
fun testMigrations() = runTest {
/** the db name must match the one in [StorageModule.providesDatabase] */
val db = migrationHelper.createDatabase("tuskyDB", 10)
val moshi = Moshi.Builder().build()
val id = 1L
val domain = "domain.site"
val token = "token"
val active = true
val accountId = "accountId"
val username = "username"
val emoji = moshi.adapter<List<Emoji>>(Types.newParameterizedType(List::class.java, Emoji::class.java), emptySet()).toJson(
listOf(
Emoji(
shortcode = "testemoji",
url = "https://some.url",
staticUrl = "https://some.url",
visibleInPicker = true,
category = null
)
)
)
val values = arrayOf(
id, domain, token, active, accountId, username, "Display Name",
"https://picture.url", true, true, true, true, true, true, true,
true, "1000", "[]", emoji, 0, false,
false, true
)
db.execSQL(
"INSERT OR REPLACE INTO `AccountEntity`(`id`,`domain`,`accessToken`,`isActive`," +
"`accountId`,`username`,`displayName`,`profilePictureUrl`,`notificationsEnabled`," +
"`notificationsMentioned`,`notificationsFollowed`,`notificationsReblogged`," +
"`notificationsFavorited`,`notificationSound`,`notificationVibration`," +
"`notificationLight`,`lastNotificationId`,`activeNotifications`,`emojis`," +
"`defaultPostPrivacy`,`defaultMediaSensitivity`,`alwaysShowSensitiveMedia`," +
"`mediaPreviewEnabled`) " +
"VALUES (nullif(?, 0),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
values
)
db.close()
// Room will run all migrations and validate the scheme afterwards
val roomDb = StorageModule.providesDatabase(
InstrumentationRegistry.getInstrumentation().context,
Converters(moshi)
)
val account = roomDb.accountDao().allAccounts().first().first()
roomDb.close()
assertEquals(id, account.id)
assertEquals(domain, account.domain)
assertEquals(token, account.accessToken)
assertEquals(active, account.isActive)
assertEquals(accountId, account.accountId)
assertEquals(username, account.username)
}
}

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 = [34])
@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,235 @@
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 = [34])
@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.SignUp, 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")),
fakeNotification(type = Notification.Type.Follow, id = "6", account = fakeAccount(id = "1"), status = null)
)
db.insert(notificationsAccount1, tuskyAccountId = 1)
db.insert(listOf(fakeNotification(id = "2000")), 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("2000", "3", "4", "5")
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 = [34])
@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,46 +1,48 @@
package com.keylesspalace.tusky.entity
import com.keylesspalace.tusky.settings.ProxyConfiguration
import org.junit.Assert
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ProxyConfigurationTest {
@Test
fun `serialized non-int is not valid proxy port`() {
Assert.assertFalse(ProxyConfiguration.isValidProxyPort("should fail"))
Assert.assertFalse(ProxyConfiguration.isValidProxyPort("1.5"))
assertFalse(ProxyConfiguration.isValidProxyPort("should fail"))
assertFalse(ProxyConfiguration.isValidProxyPort("1.5"))
}
@Test
fun `number outside port range is not valid`() {
Assert.assertFalse(ProxyConfiguration.isValidProxyPort("${ProxyConfiguration.MIN_PROXY_PORT - 1}"))
Assert.assertFalse(ProxyConfiguration.isValidProxyPort("${ProxyConfiguration.MAX_PROXY_PORT + 1}"))
assertFalse(ProxyConfiguration.isValidProxyPort("${ProxyConfiguration.MIN_PROXY_PORT - 1}"))
assertFalse(ProxyConfiguration.isValidProxyPort("${ProxyConfiguration.MAX_PROXY_PORT + 1}"))
}
@Test
fun `number in port range, inclusive of min and max, is valid`() {
Assert.assertTrue(ProxyConfiguration.isValidProxyPort(ProxyConfiguration.MIN_PROXY_PORT))
Assert.assertTrue(ProxyConfiguration.isValidProxyPort(ProxyConfiguration.MAX_PROXY_PORT))
Assert.assertTrue(ProxyConfiguration.isValidProxyPort((ProxyConfiguration.MIN_PROXY_PORT + ProxyConfiguration.MAX_PROXY_PORT) / 2))
assertTrue(ProxyConfiguration.isValidProxyPort(ProxyConfiguration.MIN_PROXY_PORT))
assertTrue(ProxyConfiguration.isValidProxyPort(ProxyConfiguration.MAX_PROXY_PORT))
assertTrue(ProxyConfiguration.isValidProxyPort((ProxyConfiguration.MIN_PROXY_PORT + ProxyConfiguration.MAX_PROXY_PORT) / 2))
}
@Test
fun `create with invalid port yields null`() {
Assert.assertNull(ProxyConfiguration.create("hostname", ProxyConfiguration.MIN_PROXY_PORT - 1))
assertNull(ProxyConfiguration.create("hostname", ProxyConfiguration.MIN_PROXY_PORT - 1))
}
@Test
fun `create with invalid hostname yields null`() {
Assert.assertNull(ProxyConfiguration.create(".", ProxyConfiguration.MIN_PROXY_PORT))
assertNull(ProxyConfiguration.create(".", ProxyConfiguration.MIN_PROXY_PORT))
}
@Test
fun `create with valid hostname and port yields the config object`() {
Assert.assertTrue(ProxyConfiguration.create("hostname", ProxyConfiguration.MIN_PROXY_PORT) is ProxyConfiguration)
assertTrue(ProxyConfiguration.create("hostname", ProxyConfiguration.MIN_PROXY_PORT) is ProxyConfiguration)
}
@Test
fun `unicode hostname allowed`() {
Assert.assertTrue(ProxyConfiguration.create("federação.social", ProxyConfiguration.MIN_PROXY_PORT) is ProxyConfiguration)
assertTrue(ProxyConfiguration.create("federação.social", ProxyConfiguration.MIN_PROXY_PORT) is ProxyConfiguration)
}
}

View file

@ -1,8 +1,11 @@
package com.keylesspalace.tusky.json
import com.keylesspalace.tusky.entity.PreviewCard
import com.keylesspalace.tusky.entity.Relationship
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
import java.util.Date
import org.junit.Assert.assertEquals
import org.junit.Test
@ -11,6 +14,7 @@ class GuardedAdapterTest {
private val moshi = Moshi.Builder()
.add(GuardedAdapter.ANNOTATION_FACTORY)
.add(Date::class.java, Rfc3339DateJsonAdapter())
.build()
@Test
@ -131,4 +135,53 @@ class GuardedAdapterTest {
moshi.adapter<Relationship>().fromJson(jsonInput)
)
}
@Test
fun `should deserialize PreviewCard when attribute 'published_at' is invalid`() {
// https://github.com/tuskyapp/Tusky/issues/4992
val jsonInput = """{
"url": "https://www.cbc.ca/amp/1.7484477",
"title": "Canada reconsidering F-35 purchase amid tensions with Washington, says minister",
"description": "Canada is looking at cancelling a major portion of its purchase of U.S.-built F-35 stealth fighters and plans on opening talks with rival aircraft makers, Defence Minister Bill Blair said.",
"language": "en",
"type": "link",
"author_name": "Murray Brewster",
"author_url": "",
"provider_name": "CBC",
"provider_url": "",
"html": "",
"width": 0,
"height": 0,
"image": "https://files.mastodon.social/cache/preview_cards/images/137/231/445/original/0f63297db3ac7362.jpg",
"image_description": "",
"embed_url": "",
"blurhash": "U74#eeXoK9nLrVWZS+nfXVaenKkXTOjErobx",
"published_at": "57171-08-04T06:31:30.000Z",
"authors": [
{
"name": "Murray Brewster",
"url": "",
"account": null
}
]
}"""
assertEquals(
PreviewCard(
url = "https://www.cbc.ca/amp/1.7484477",
title = "Canada reconsidering F-35 purchase amid tensions with Washington, says minister",
description = "Canada is looking at cancelling a major portion of its purchase of U.S.-built F-35 stealth fighters and plans on opening talks with rival aircraft makers, Defence Minister Bill Blair said.",
type = "link",
authorName = "Murray Brewster",
providerName = "CBC",
image = "https://files.mastodon.social/cache/preview_cards/images/137/231/445/original/0f63297db3ac7362.jpg",
width = 0,
height = 0,
blurhash = "U74#eeXoK9nLrVWZS+nfXVaenKkXTOjErobx",
publishedAt = null,
),
moshi.adapter<PreviewCard>().fromJson(jsonInput)
)
}
}

View file

@ -0,0 +1,126 @@
package com.keylesspalace.tusky.network
import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.entity.Instance
import com.squareup.moshi.Moshi
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
class ApiFactoryTest {
private val mockWebServer = MockWebServer()
private val okHttpClient = OkHttpClient.Builder().build()
private val moshi = Moshi.Builder().build()
@Before
fun setup() {
mockWebServer.start()
}
@After
fun teardown() {
mockWebServer.shutdown()
}
private fun retrofit() = Retrofit.Builder()
.baseUrl("http://${MastodonApi.PLACEHOLDER_DOMAIN}:${mockWebServer.port}")
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
.build()
@Test
fun `should make request to the active account's instance`() = runTest {
mockInstanceResponse()
val account = AccountEntity(
id = 1,
domain = mockWebServer.hostName,
accessToken = "fakeToken",
clientId = "fakeId",
clientSecret = "fakeSecret",
isActive = true
)
val retrofit = retrofit()
val api: MastodonApi = apiForAccount(account, okHttpClient, retrofit, "http://", mockWebServer.port)
val instanceResponse = api.getInstance()
assertTrue(instanceResponse.isSuccess)
assertEquals("Bearer fakeToken", mockWebServer.takeRequest().getHeader("Authorization"))
}
@Test
fun `should make request to instance requested in special header when account active`() = runTest {
mockInstanceResponse()
val account = AccountEntity(
id = 1,
domain = mockWebServer.hostName,
accessToken = "fakeToken",
clientId = "fakeId",
clientSecret = "fakeSecret",
isActive = true
)
val retrofit = retrofit()
val api: MastodonApi = apiForAccount(account, okHttpClient, retrofit, "http://", mockWebServer.port)
val instanceResponse = api.getInstance(domain = mockWebServer.hostName)
assertTrue(instanceResponse.isSuccess)
assertNull(mockWebServer.takeRequest().getHeader("Authorization"))
}
@Test
fun `should make request to instance requested in special header when no account active`() = runTest {
mockInstanceResponse()
val retrofit = retrofit()
val api: MastodonApi = apiForAccount(null, okHttpClient, retrofit, "http://", mockWebServer.port)
val instanceResponse = api.getInstance(domain = mockWebServer.hostName)
assertTrue(instanceResponse.isSuccess)
assertNull(mockWebServer.takeRequest().getHeader("Authorization"))
}
@Test
fun `should fail when current instance is requested but no user is logged in`() = runTest {
mockInstanceResponse()
val retrofit = retrofit()
val api: MastodonApi = apiForAccount(null, okHttpClient, retrofit, "http://", mockWebServer.port)
val instanceResponse = api.getInstance()
assertTrue(instanceResponse.isFailure)
assertEquals(0, mockWebServer.requestCount)
}
private fun mockInstanceResponse() {
mockWebServer.enqueue(
MockResponse()
.setBody(
moshi.adapter(Instance::class.java).toJson(
Instance(
domain = "example.org",
version = "1.0.0"
)
)
)
)
}
}

View file

@ -1,142 +0,0 @@
package com.keylesspalace.tusky.network
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.mock
class InstanceSwitchAuthInterceptorTest {
private val mockWebServer = MockWebServer()
@Before
fun setup() {
mockWebServer.start()
}
@After
fun teardown() {
mockWebServer.shutdown()
}
@Test
fun `should make regular request when requested`() {
mockWebServer.enqueue(MockResponse())
val accountManager: AccountManager = mock {
on { activeAccount } doAnswer { null }
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
.build()
val request = Request.Builder()
.get()
.url(mockWebServer.url("/test"))
.build()
val response = okHttpClient.newCall(request).execute()
assertEquals(200, response.code)
}
@Test
fun `should make request to instance requested in special header`() {
mockWebServer.enqueue(MockResponse())
val accountManager: AccountManager = mock {
on { activeAccount } doAnswer {
AccountEntity(
id = 1,
domain = "test.domain",
accessToken = "fakeToken",
clientId = "fakeId",
clientSecret = "fakeSecret",
isActive = true
)
}
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
.build()
val request = Request.Builder()
.get()
.url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + ":" + mockWebServer.port + "/test")
.header(MastodonApi.DOMAIN_HEADER, mockWebServer.hostName)
.build()
val response = okHttpClient.newCall(request).execute()
assertEquals(200, response.code)
assertNull(mockWebServer.takeRequest().getHeader("Authorization"))
}
@Test
fun `should make request to current instance when requested and user is logged in`() {
mockWebServer.enqueue(MockResponse())
val accountManager: AccountManager = mock {
on { activeAccount } doAnswer {
AccountEntity(
id = 1,
domain = mockWebServer.hostName,
accessToken = "fakeToken",
clientId = "fakeId",
clientSecret = "fakeSecret",
isActive = true
)
}
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
.build()
val request = Request.Builder()
.get()
.url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + ":" + mockWebServer.port + "/test")
.build()
val response = okHttpClient.newCall(request).execute()
assertEquals(200, response.code)
assertEquals("Bearer fakeToken", mockWebServer.takeRequest().getHeader("Authorization"))
}
@Test
fun `should fail to make request when request to current instance is requested but no user is logged in`() {
mockWebServer.enqueue(MockResponse())
val accountManager: AccountManager = mock {
on { activeAccount } doAnswer { null }
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
.build()
val request = Request.Builder()
.get()
.url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + "/test")
.build()
val response = okHttpClient.newCall(request).execute()
assertEquals(400, response.code)
assertEquals(0, mockWebServer.requestCount)
}
}

View file

@ -8,7 +8,7 @@ import com.keylesspalace.tusky.appstore.StatusChangedEvent
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import java.util.Date
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Before
@ -21,7 +21,7 @@ import org.robolectric.annotation.Config
import retrofit2.HttpException
import retrofit2.Response
@Config(sdk = [28])
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class TimelineCasesTest {
@ -39,23 +39,21 @@ class TimelineCasesTest {
}
@Test
fun `pin success emits StatusChangedEvent`() {
fun `pin success emits StatusChangedEvent`() = runTest {
val pinnedStatus = mockStatus(pinned = true)
api.stub {
onBlocking { pinStatus(statusId) } doReturn NetworkResult.success(pinnedStatus)
}
runBlocking {
eventHub.events.test {
timelineCases.pin(statusId, true)
assertEquals(StatusChangedEvent(pinnedStatus), awaitItem())
}
eventHub.events.test {
timelineCases.pin(statusId, true)
assertEquals(StatusChangedEvent(pinnedStatus), awaitItem())
}
}
@Test
fun `pin failure with server error throws TimelineError with server message`() {
fun `pin failure with server error throws TimelineError with server message`() = runTest {
api.stub {
onBlocking { pinStatus(statusId) } doReturn NetworkResult.failure(
HttpException(
@ -66,12 +64,10 @@ class TimelineCasesTest {
)
)
}
runBlocking {
assertEquals(
"Validation Failed: You have already pinned the maximum number of toots",
timelineCases.pin(statusId, true).exceptionOrNull()?.message
)
}
assertEquals(
"Validation Failed: You have already pinned the maximum number of toots",
timelineCases.pin(statusId, true).exceptionOrNull()?.message
)
}
private fun mockStatus(pinned: Boolean = false): Status {

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

@ -1,13 +1,8 @@
package com.keylesspalace.tusky.util
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
class HttpHeaderLinkTest {
data class TestData(val name: String, val input: String, val want: List<HttpHeaderLink>)

View file

@ -9,13 +9,17 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.LinkListener
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.robolectric.annotation.Config
@Config(sdk = [28])
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class LinkHelperTest {
private val listener = object : LinkListener {
@ -51,7 +55,7 @@ class LinkHelperTest {
urlSpans = builder.getSpans(0, builder.length, URLSpan::class.java)
for (span in urlSpans) {
Assert.assertNotNull(mentions.firstOrNull { it.url == span.url })
assertNotNull(mentions.firstOrNull { it.url == span.url })
}
}
@ -72,7 +76,7 @@ class LinkHelperTest {
urlSpans = builder.getSpans(0, builder.length, URLSpan::class.java)
for (span in urlSpans) {
Assert.assertEquals(nonMentionUrl, span.url)
assertEquals(nonMentionUrl, span.url)
}
}
@ -81,8 +85,8 @@ class LinkHelperTest {
for (tag in tags) {
for (mutatedTagName in listOf(tag.name, tag.name.uppercase(), tag.name.lowercase())) {
val tagName = getTagName("#$mutatedTagName", tags)
Assert.assertNotNull(tagName)
Assert.assertNotNull(tags.firstOrNull { it.name == tagName })
assertNotNull(tagName)
assertNotNull(tags.firstOrNull { it.name == tagName })
}
}
}
@ -93,22 +97,22 @@ class LinkHelperTest {
for (tag in tags) {
val mutatedTagName = String(tag.name.map { mutator[it] ?: it }.toCharArray())
val tagName = getTagName("#$mutatedTagName", tags)
Assert.assertNotNull(tagName)
Assert.assertNotNull(tags.firstOrNull { it.name == tagName })
assertNotNull(tagName)
assertNotNull(tags.firstOrNull { it.name == tagName })
}
}
@Test
fun hashedUrlSpans_withNoMatchingTag_areNotModified() {
for (tag in tags) {
Assert.assertNull(getTagName("#not${tag.name}", tags))
assertNull(getTagName("#not${tag.name}", tags))
}
}
@Test
fun whenTagsAreNull_tagNameIsGeneratedFromText() {
for (tag in tags) {
Assert.assertEquals(tag.name, getTagName("#${tag.name}", null))
assertEquals(tag.name, getTagName("#${tag.name}", null))
}
}
@ -120,7 +124,7 @@ class LinkHelperTest {
"http:/foo.bar",
"c:/foo/bar"
).forEach {
Assert.assertEquals("", getDomain(it))
assertEquals("", getDomain(it))
}
}
@ -142,7 +146,7 @@ class LinkHelperTest {
"https://$domain/foo/bar.html?argument=value",
"https://$domain/foo/bar.html?argument=value&otherArgument=otherValue"
).forEach { url ->
Assert.assertEquals(domain, getDomain(url))
assertEquals(domain, getDomain(url))
}
}
}
@ -155,7 +159,7 @@ class LinkHelperTest {
"http://www.localhost" to "localhost",
"https://wwwexample.com/" to "wwwexample.com"
).forEach { (url, domain) ->
Assert.assertEquals(domain, getDomain(url))
assertEquals(domain, getDomain(url))
}
}
@ -167,11 +171,11 @@ class LinkHelperTest {
val content = SpannableStringBuilder()
content.append(displayedContent, URLSpan(maliciousUrl), 0)
val oldContent = content.toString()
Assert.assertEquals(
textView.context.getString(R.string.url_domain_notifier, displayedContent, maliciousDomain),
assertEquals(
displayedContent + " " + textView.context.getString(R.string.url_domain_notifier, maliciousDomain),
markupHiddenUrls(textView, content).toString()
)
Assert.assertEquals(oldContent, content.toString())
assertEquals(oldContent, content.toString())
}
@Test
@ -181,8 +185,8 @@ class LinkHelperTest {
val maliciousUrl = "https://$maliciousDomain/to/go"
val content = SpannableStringBuilder()
content.append(displayedContent, URLSpan(maliciousUrl), 0)
Assert.assertEquals(
textView.context.getString(R.string.url_domain_notifier, displayedContent, maliciousDomain),
assertEquals(
displayedContent + " " + textView.context.getString(R.string.url_domain_notifier, maliciousDomain),
markupHiddenUrls(textView, content).toString()
)
}
@ -198,7 +202,7 @@ class LinkHelperTest {
val markedUpContent = markupHiddenUrls(textView, content)
for (domain in domains) {
Assert.assertTrue(markedUpContent.contains(textView.context.getString(R.string.url_domain_notifier, displayedContent, domain)))
assertTrue(markedUpContent.contains(displayedContent + " " + textView.context.getString(R.string.url_domain_notifier, domain)))
}
}
@ -216,7 +220,7 @@ class LinkHelperTest {
.append("$domain/", URLSpan("https://www.$domain"), 0)
val markedUpContent = markupHiddenUrls(textView, content)
Assert.assertFalse(markedUpContent.contains("🔗"))
assertFalse(markedUpContent.contains("🔗"))
}
@Test
@ -229,7 +233,7 @@ class LinkHelperTest {
.append("Some Place https://some.place/path", URLSpan("https://some.place/path"), 0)
val markedUpContent = markupHiddenUrls(textView, content)
Assert.assertFalse(markedUpContent.contains("🔗"))
assertFalse(markedUpContent.contains("🔗"))
}
@Test
@ -249,8 +253,9 @@ class LinkHelperTest {
"Another Place | https://another.place/",
"Another Place https://another.place/path"
)
print(markedUpContent)
asserts.forEach {
Assert.assertTrue(markedUpContent.contains(textView.context.getString(R.string.url_domain_notifier, it, "some.place")))
assertTrue(markedUpContent.contains(it + " " + textView.context.getString(R.string.url_domain_notifier, "some.place")))
}
}
@ -264,7 +269,7 @@ class LinkHelperTest {
val markedUpContent = markupHiddenUrls(textView, builder)
for (mention in mentions) {
Assert.assertFalse(markedUpContent.contains("${getDomain(mention.url)})"))
assertFalse(markedUpContent.contains("${getDomain(mention.url)})"))
}
}
@ -278,7 +283,7 @@ class LinkHelperTest {
val markedUpContent = markupHiddenUrls(textView, builder)
for (mention in mentions) {
Assert.assertFalse(markedUpContent.contains("${getDomain(mention.url)})"))
assertFalse(markedUpContent.contains("${getDomain(mention.url)})"))
}
}
@ -292,7 +297,7 @@ class LinkHelperTest {
val markedUpContent = markupHiddenUrls(textView, builder)
for (tag in tags) {
Assert.assertFalse(markedUpContent.contains("${getDomain(tag.url)})"))
assertFalse(markedUpContent.contains("${getDomain(tag.url)})"))
}
}
@ -306,7 +311,90 @@ class LinkHelperTest {
val markedUpContent = markupHiddenUrls(textView, builder)
for (tag in tags) {
Assert.assertFalse(markedUpContent.contains("${getDomain(tag.url)})"))
assertFalse(markedUpContent.contains("${getDomain(tag.url)})"))
}
}
@Test
fun `get trailing hashtags with empty content returns empty list`() {
val (endOfContent, trailingHashtags) = getTrailingHashtags(SpannableStringBuilder(""))
assertEquals(0, endOfContent)
assert(trailingHashtags.isEmpty())
}
@Test
fun `get trailing hashtags with no hashtags returns empty list`() {
val (endOfContent, trailingHashtags) = getTrailingHashtags(SpannableStringBuilder("some untagged content"))
assertEquals(21, endOfContent)
assert(trailingHashtags.isEmpty())
}
@Test
fun `get trailing hashtags with all inline hashtags returns empty list`() {
val (endOfContent, trailingHashtags) = getTrailingHashtags(SpannableStringBuilder("some #inline #tagged #content"))
assertEquals(29, endOfContent)
assert(trailingHashtags.isEmpty())
}
@Test
fun `get trailing hashtags with only hashtags returns empty list`() {
val (endOfContent, trailingHashtags) = getTrailingHashtags(SpannableStringBuilder("#some #inline #tagged #content"))
assertEquals(0, endOfContent)
assert(trailingHashtags.isEmpty())
}
@Test
fun `get trailing hashtags with one tag`() {
val content = SpannableStringBuilder("some content followed by tags:\n").apply {
tags.first().let { append("#${it.name}", URLSpan(it.url), 0) }
}
val (endOfContent, trailingHashtags) = getTrailingHashtags(content)
assertEquals(30, endOfContent)
assertEquals(tags.first().name, trailingHashtags.single().name)
assertEquals(tags.first().url, trailingHashtags.single().url)
}
@Test
fun `get trailing hashtags with multiple tags`() {
for (separator in listOf(" ", "\t", "\n", "\r\n")) {
val content = SpannableStringBuilder("some content followed by tags:\n").apply {
for (tag in tags) {
append(separator)
append("#${tag.name}", URLSpan(tag.url), 0)
append(separator)
}
}
val (endOfContent, trailingHashtags) = getTrailingHashtags(content)
assertEquals(30, endOfContent)
assertEquals(tags.size, trailingHashtags.size)
tags.forEachIndexed { index, tag ->
assertEquals(tag.name, trailingHashtags[index].name)
assertEquals(tag.url, trailingHashtags[index].url)
}
}
}
@Test
fun `get trailing hashtags ignores inline tags`() {
for (separator in listOf(" ", "\t", "\n", "\r\n")) {
val content = SpannableStringBuilder("some content with inline tag ").apply {
append("#inline", URLSpan("https://example.com/tag/inline"), 0)
append(" followed by trailing tags\n")
for (tag in tags) {
append(separator)
append("#${tag.name}", URLSpan(tag.url), 0)
append(separator)
}
}
val (_, trailingHashtags) = getTrailingHashtags(content)
assertEquals(tags.size, trailingHashtags.size)
tags.forEachIndexed { index, tag ->
assertEquals(tag.name, trailingHashtags[index].name)
assertEquals(tag.url, trailingHashtags[index].url)
}
}
}
@ -315,7 +403,7 @@ class LinkHelperTest {
companion object {
@Parameterized.Parameters(name = "match_{0}")
@JvmStatic
fun data(): Iterable<Any> {
fun data(): Iterable<Array<Any>> {
return listOf(
arrayOf("https://mastodon.foo.bar/@User", true),
arrayOf("http://mastodon.foo.bar/@abc123", true),
@ -375,7 +463,7 @@ class LinkHelperTest {
@Test
fun test() {
Assert.assertEquals(expectedResult, looksLikeMastodonUrl(url))
assertEquals(expectedResult, looksLikeMastodonUrl(url))
}
}
}

View file

@ -3,46 +3,48 @@ 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 org.junit.Assert
import com.keylesspalace.tusky.db.entity.AccountEntity
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.robolectric.annotation.Config
@Config(sdk = [28])
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class LocaleUtilsTest {
@Test
fun initialLanguagesContainReplySelectedAppAndSystem() {
val expectedLanguages = arrayOf<String?>("yi", "tok", "da", "fr", "sv", "kab")
val languages = getMockedInitialLanguages(expectedLanguages)
Assert.assertArrayEquals(expectedLanguages, languages.subList(0, expectedLanguages.size).toTypedArray())
assertArrayEquals(expectedLanguages, languages.subList(0, expectedLanguages.size).toTypedArray())
}
@Test
fun whenReplyLanguageIsNull_DefaultLanguageIsFirst() {
val defaultLanguage = "tok"
val languages = getMockedInitialLanguages(arrayOf(null, defaultLanguage, "da", "fr", "sv", "kab"))
Assert.assertEquals(defaultLanguage, languages[0])
assertEquals(defaultLanguage, languages[0])
}
@Test
fun initialLanguagesAreDistinct() {
val defaultLanguage = "da"
val languages = getMockedInitialLanguages(arrayOf(defaultLanguage, defaultLanguage, "fr", defaultLanguage, "kab", defaultLanguage))
Assert.assertEquals(1, languages.count { it == defaultLanguage })
assertEquals(1, languages.count { it == defaultLanguage })
}
@Test
fun initialLanguageDeduplicationDoesNotReorder() {
val defaultLanguage = "da"
Assert.assertEquals(
assertEquals(
defaultLanguage,
getMockedInitialLanguages(arrayOf(defaultLanguage, defaultLanguage, "fr", defaultLanguage, "kab", defaultLanguage))[0]
)
Assert.assertEquals(
assertEquals(
defaultLanguage,
getMockedInitialLanguages(arrayOf(null, defaultLanguage, "fr", defaultLanguage, "kab", defaultLanguage))[0]
)
@ -51,7 +53,7 @@ class LocaleUtilsTest {
@Test
fun emptyInitialLanguagesAreDropped() {
val languages = getMockedInitialLanguages(arrayOf("", "", "fr", "", "kab", ""))
Assert.assertFalse(languages.any { it.isEmpty() })
assertFalse(languages.any { it.isEmpty() })
}
private fun getMockedInitialLanguages(configuredLanguages: Array<String?>): List<String> {

View file

@ -3,7 +3,7 @@ package com.keylesspalace.tusky.util
import java.util.Locale
import kotlin.math.pow
import org.junit.AfterClass
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
@ -35,7 +35,7 @@ class NumberUtilsTest(private val input: Long, private val want: String) {
@Parameterized.Parameters(name = "formatNumber_{0}")
@JvmStatic
fun data(): Iterable<Any> {
fun data(): Iterable<Array<Any>> {
return listOf(
arrayOf(0, "0"),
arrayOf(1, "1"),
@ -65,6 +65,6 @@ class NumberUtilsTest(private val input: Long, private val want: String) {
@Test
fun test() {
Assert.assertEquals(want, formatNumber(input, 1000))
assertEquals(want, formatNumber(input, 1000))
}
}

View file

@ -10,7 +10,7 @@ import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.annotation.Config
@Config(sdk = [28])
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class RickRollTest {
private lateinit var activity: Activity

View file

@ -8,7 +8,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@Config(sdk = [28])
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class SmartLengthInputFilterTest {

View file

@ -1,29 +1,78 @@
package com.keylesspalace.tusky.util
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.R
import androidx.test.platform.app.InstrumentationRegistry
import java.util.Locale
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.robolectric.annotation.Config
private const val STATUS_CREATED_AT_NOW = "test"
@Config(sdk = [28])
@Config(sdk = [34])
@RunWith(AndroidJUnit4::class)
class TimestampUtilsTest {
private val ctx: Context = mock {
on { getString(R.string.status_created_at_now) } doReturn STATUS_CREATED_AT_NOW
companion object {
private lateinit var locale: Locale
@BeforeClass
@JvmStatic
fun beforeClass() {
locale = Locale.getDefault()
Locale.setDefault(Locale.ENGLISH)
}
@AfterClass
@JvmStatic
fun afterClass() {
Locale.setDefault(locale)
}
}
val context = InstrumentationRegistry.getInstrumentation().targetContext
@Test
fun `should return 'now' for small timespans`() {
assertEquals("now", getRelativeTimeSpanString(context, 0, 300))
assertEquals("now", getRelativeTimeSpanString(context, 300, 0))
assertEquals("now", getRelativeTimeSpanString(context, 501, 0))
assertEquals("now", getRelativeTimeSpanString(context, 0, 999))
}
@Test
fun shouldShowNowForSmallTimeSpans() {
assertEquals(STATUS_CREATED_AT_NOW, getRelativeTimeSpanString(ctx, 0, 300))
assertEquals(STATUS_CREATED_AT_NOW, getRelativeTimeSpanString(ctx, 300, 0))
assertEquals(STATUS_CREATED_AT_NOW, getRelativeTimeSpanString(ctx, 501, 0))
assertEquals(STATUS_CREATED_AT_NOW, getRelativeTimeSpanString(ctx, 0, 999))
fun `should return 'in --' when then is after now`() {
assertEquals("in 49s", getRelativeTimeSpanString(context, 49.seconds.inWholeMilliseconds, 0))
assertEquals("in 34m", getRelativeTimeSpanString(context, 37.minutes.inWholeMilliseconds, 3.minutes.inWholeMilliseconds))
assertEquals("in 7h", getRelativeTimeSpanString(context, 10.hours.inWholeMilliseconds, 3.hours.inWholeMilliseconds))
assertEquals("in 10d", getRelativeTimeSpanString(context, 10.days.inWholeMilliseconds, 0))
assertEquals("in 4y", getRelativeTimeSpanString(context, 800.days.inWholeMilliseconds + (4 * 365).days.inWholeMilliseconds, 800.days.inWholeMilliseconds))
}
@Test
fun `should return correct timespans`() {
assertEquals("49s", getRelativeTimeSpanString(context, 0, 49.seconds.inWholeMilliseconds))
assertEquals("34m", getRelativeTimeSpanString(context, 3.minutes.inWholeMilliseconds, 37.minutes.inWholeMilliseconds))
assertEquals("7h", getRelativeTimeSpanString(context, 3.hours.inWholeMilliseconds, 10.hours.inWholeMilliseconds))
assertEquals("10d", getRelativeTimeSpanString(context, 0, 10.days.inWholeMilliseconds))
assertEquals("4y", getRelativeTimeSpanString(context, 800.days.inWholeMilliseconds, 800.days.inWholeMilliseconds + (4 * 365).days.inWholeMilliseconds))
}
@Test
fun `should return correct poll duration`() {
assertEquals("1 second left", formatPollDuration(context, 1.seconds.inWholeMilliseconds, 0))
assertEquals("49 seconds left", formatPollDuration(context, 49.seconds.inWholeMilliseconds, 0))
assertEquals("1 minute left", formatPollDuration(context, 37.minutes.inWholeMilliseconds, 36.minutes.inWholeMilliseconds))
assertEquals("34 minutes left", formatPollDuration(context, 37.minutes.inWholeMilliseconds, 3.minutes.inWholeMilliseconds))
assertEquals("1 hour left", formatPollDuration(context, 10.hours.inWholeMilliseconds, 9.hours.inWholeMilliseconds))
assertEquals("7 hours left", formatPollDuration(context, 10.hours.inWholeMilliseconds, 3.hours.inWholeMilliseconds))
assertEquals("1 day left", formatPollDuration(context, 1.days.inWholeMilliseconds, 0))
assertEquals("10 days left", formatPollDuration(context, 10.days.inWholeMilliseconds, 0))
assertEquals("1460 days left", formatPollDuration(context, 800.days.inWholeMilliseconds + (4 * 365).days.inWholeMilliseconds, 800.days.inWholeMilliseconds))
}
}