Merge tag 'v25.2' into develop

# Conflicts:
#	README.md
#	app/build.gradle
#	app/lint-baseline.xml
#	app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt
#	app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt
#	app/src/main/res/layout/activity_about.xml
#	app/src/main/res/layout/item_emoji_pref.xml
#	app/src/main/res/values-ar/strings.xml
#	app/src/main/res/values-bg/strings.xml
#	app/src/main/res/values-cy/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-fa/strings.xml
#	app/src/main/res/values-gd/strings.xml
#	app/src/main/res/values-gl/strings.xml
#	app/src/main/res/values-hu/strings.xml
#	app/src/main/res/values-is/strings.xml
#	app/src/main/res/values-it/strings.xml
#	app/src/main/res/values-ja/strings.xml
#	app/src/main/res/values-nl/strings.xml
#	app/src/main/res/values-oc/strings.xml
#	app/src/main/res/values-pt-rBR/strings.xml
#	app/src/main/res/values-pt-rPT/strings.xml
#	app/src/main/res/values-ru/strings.xml
#	app/src/main/res/values-si/strings.xml
#	app/src/main/res/values-sv/strings.xml
#	app/src/main/res/values-tr/strings.xml
#	app/src/main/res/values-uk/strings.xml
#	app/src/main/res/values-vi/strings.xml
#	app/src/main/res/values-zh-rCN/strings.xml
#	app/src/main/res/values/strings.xml
#	fastlane/metadata/android/ru/full_description.txt
#	fastlane/metadata/android/zh-Hans/full_description.txt
This commit is contained in:
Mike Barnes 2026-01-02 18:27:41 +11:00
commit 875013e47f
630 changed files with 22153 additions and 18732 deletions

View file

@ -16,14 +16,19 @@
package com.keylesspalace.tusky
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.TestScheduler
import java.util.Date
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@ -34,9 +39,8 @@ import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.Mockito.eq
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import java.util.Date
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalCoroutinesApi::class)
class BottomSheetActivityTest {
@get:Rule
@ -48,8 +52,7 @@ class BottomSheetActivityTest {
private val statusQuery = "http://mastodon.foo.bar/@User/345678"
private val nonexistentStatusQuery = "http://mastodon.foo.bar/@User/345678000"
private val nonMastodonQuery = "http://medium.com/@correspondent/345678"
private val emptyCallback = Single.just(SearchResult(emptyList(), emptyList(), emptyList()))
private val testScheduler = TestScheduler()
private val emptyResult = NetworkResult.success(SearchResult(emptyList(), emptyList(), emptyList()))
private val account = TimelineAccount(
id = "1",
@ -60,7 +63,7 @@ class BottomSheetActivityTest {
url = "http://mastodon.foo.bar/@User",
avatar = ""
)
private val accountSingle = Single.just(SearchResult(listOf(account), emptyList(), emptyList()))
private val accountResult = NetworkResult.success(SearchResult(listOf(account), emptyList(), emptyList()))
private val status = Status(
id = "1",
@ -91,20 +94,17 @@ class BottomSheetActivityTest {
poll = null,
card = null,
language = null,
filtered = null
filtered = emptyList()
)
private val statusSingle = Single.just(SearchResult(emptyList(), listOf(status), emptyList()))
private val statusResult = NetworkResult.success(SearchResult(emptyList(), listOf(status), emptyList()))
@Before
fun setup() {
RxJavaPlugins.setIoSchedulerHandler { testScheduler }
RxAndroidPlugins.setMainThreadSchedulerHandler { testScheduler }
apiMock = mock {
on { searchObservable(eq(accountQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountSingle
on { searchObservable(eq(statusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn statusSingle
on { searchObservable(eq(nonexistentStatusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountSingle
on { searchObservable(eq(nonMastodonQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn emptyCallback
onBlocking { search(eq(accountQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountResult
onBlocking { search(eq(statusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn statusResult
onBlocking { search(eq(nonexistentStatusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountResult
onBlocking { search(eq(nonMastodonQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn emptyResult
}
activity = FakeBottomSheetActivity(apiMock)
@ -157,86 +157,134 @@ class BottomSheetActivityTest {
}
@Test
fun search_inIdealConditions_returnsRequestedResults_forAccount() {
activity.viewUrl(accountQuery)
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS)
assertEquals(account.id, activity.accountId)
}
@Test
fun search_inIdealConditions_returnsRequestedResults_forStatus() {
activity.viewUrl(statusQuery)
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS)
assertEquals(status.id, activity.statusId)
}
@Test
fun search_inIdealConditions_returnsRequestedResults_forNonMastodonURL() {
activity.viewUrl(nonMastodonQuery)
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS)
assertEquals(nonMastodonQuery, activity.link)
}
@Test
fun search_withNoResults_appliesRequestedFallbackBehavior() {
for (fallbackBehavior in listOf(PostLookupFallbackBehavior.OPEN_IN_BROWSER, PostLookupFallbackBehavior.DISPLAY_ERROR)) {
activity.viewUrl(nonMastodonQuery, fallbackBehavior)
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS)
assertEquals(nonMastodonQuery, activity.link)
assertEquals(fallbackBehavior, activity.fallbackBehavior)
fun search_inIdealConditions_returnsRequestedResults_forAccount() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
activity.viewUrl(accountQuery)
testScheduler.advanceTimeBy(100.milliseconds)
assertEquals(account.id, activity.accountId)
} finally {
Dispatchers.resetMain()
}
}
@Test
fun search_doesNotRespectUnrelatedResult() {
activity.viewUrl(nonexistentStatusQuery)
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS)
assertEquals(nonexistentStatusQuery, activity.link)
assertEquals(null, activity.accountId)
fun search_inIdealConditions_returnsRequestedResults_forStatus() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
activity.viewUrl(statusQuery)
testScheduler.advanceTimeBy(100.milliseconds)
assertEquals(status.id, activity.statusId)
} finally {
Dispatchers.resetMain()
}
}
@Test
fun search_withCancellation_doesNotLoadUrl_forAccount() {
activity.viewUrl(accountQuery)
assertTrue(activity.isSearching())
activity.cancelActiveSearch()
assertFalse(activity.isSearching())
assertEquals(null, activity.accountId)
fun search_inIdealConditions_returnsRequestedResults_forNonMastodonURL() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
activity.viewUrl(nonMastodonQuery)
testScheduler.advanceTimeBy(100.milliseconds)
assertEquals(nonMastodonQuery, activity.link)
} finally {
Dispatchers.resetMain()
}
}
@Test
fun search_withCancellation_doesNotLoadUrl_forStatus() {
activity.viewUrl(accountQuery)
activity.cancelActiveSearch()
assertEquals(null, activity.accountId)
fun search_withNoResults_appliesRequestedFallbackBehavior() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
for (fallbackBehavior in listOf(
PostLookupFallbackBehavior.OPEN_IN_BROWSER,
PostLookupFallbackBehavior.DISPLAY_ERROR
)) {
activity.viewUrl(nonMastodonQuery, fallbackBehavior)
testScheduler.advanceTimeBy(100.milliseconds)
assertEquals(nonMastodonQuery, activity.link)
assertEquals(fallbackBehavior, activity.fallbackBehavior)
}
} finally {
Dispatchers.resetMain()
}
}
@Test
fun search_withCancellation_doesNotLoadUrl_forNonMastodonURL() {
activity.viewUrl(nonMastodonQuery)
activity.cancelActiveSearch()
assertEquals(null, activity.searchUrl)
fun search_doesNotRespectUnrelatedResult() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
activity.viewUrl(nonexistentStatusQuery)
testScheduler.advanceTimeBy(100.milliseconds)
assertEquals(nonexistentStatusQuery, activity.link)
assertEquals(null, activity.accountId)
} finally {
Dispatchers.resetMain()
}
}
@Test
fun search_withPreviousCancellation_completes() {
// begin/cancel account search
activity.viewUrl(accountQuery)
activity.cancelActiveSearch()
fun search_withCancellation_doesNotLoadUrl_forAccount() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
activity.viewUrl(accountQuery)
assertTrue(activity.isSearching())
activity.cancelActiveSearch()
assertFalse(activity.isSearching())
assertEquals(null, activity.accountId)
} finally {
Dispatchers.resetMain()
}
}
// begin status search
activity.viewUrl(statusQuery)
@Test
fun search_withCancellation_doesNotLoadUrl_forStatus() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
activity.viewUrl(accountQuery)
activity.cancelActiveSearch()
assertEquals(null, activity.accountId)
} finally {
Dispatchers.resetMain()
}
}
// ensure that search is still ongoing
assertTrue(activity.isSearching())
@Test
fun search_withCancellation_doesNotLoadUrl_forNonMastodonURL() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
activity.viewUrl(nonMastodonQuery)
activity.cancelActiveSearch()
assertEquals(null, activity.searchUrl)
} finally {
Dispatchers.resetMain()
}
}
// return searchResults
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS)
@Test
fun search_withPreviousCancellation_completes() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
// begin/cancel account search
activity.viewUrl(accountQuery)
activity.cancelActiveSearch()
// ensure that the result of the status search was recorded
// and the account search wasn't
assertEquals(status.id, activity.statusId)
assertEquals(null, activity.accountId)
// begin status search
activity.viewUrl(statusQuery)
// ensure that search is still ongoing
assertTrue(activity.isSearching())
// return searchResults
testScheduler.advanceTimeBy(100.milliseconds)
// ensure that the result of the status search was recorded
// and the account search wasn't
assertEquals(status.id, activity.statusId)
assertEquals(null, activity.accountId)
} finally {
Dispatchers.resetMain()
}
}
class FakeBottomSheetActivity(api: MastodonApi) : BottomSheetActivity() {

View file

@ -26,14 +26,14 @@ 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 java.time.Instant
import java.util.Date
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.robolectric.annotation.Config
import java.time.Instant
import java.util.Date
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
@ -334,14 +334,14 @@ class FilterV1Test {
PollOption(it, 0)
},
voted = false,
ownVotes = null
ownVotes = emptyList()
)
} else {
null
},
card = null,
language = null,
filtered = null
filtered = emptyList()
)
}
}

View file

@ -16,6 +16,9 @@ import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.TimelineAccount
import java.util.Date
import kotlinx.coroutines.test.TestScope
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
@ -27,7 +30,6 @@ import org.robolectric.Robolectric
import org.robolectric.Shadows.shadowOf
import org.robolectric.android.util.concurrent.BackgroundExecutor.runInBackground
import org.robolectric.annotation.Config
import java.util.Date
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
@ -59,6 +61,11 @@ class MainActivityTest {
WorkManagerTestInitHelper.initializeTestWorkManager(context)
}
@After
fun teardown() {
WorkManagerTestInitHelper.closeWorkDatabase()
}
@Test
fun `clicking notification of type FOLLOW shows notification tab`() {
val intent = showNotification(Notification.Type.FOLLOW)
@ -126,6 +133,8 @@ class MainActivityTest {
on { activeAccount } doReturn accountEntity
}
activity.draftsAlert = mock {}
activity.shareShortcutHelper = mock {}
activity.externalScope = TestScope()
activity.mastodonApi = mock {
onBlocking { accountVerifyCredentials() } doReturn NetworkResult.success(account)
onBlocking { listAnnouncements(false) } doReturn NetworkResult.success(emptyList())

View file

@ -1,9 +1,10 @@
package com.keylesspalace.tusky
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.gson.Gson
import com.keylesspalace.tusky.di.NetworkModule
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.squareup.moshi.adapter
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
@ -37,7 +38,7 @@ class StatusComparisonTest {
assertNotEquals(createStatus(note = "Test"), createStatus(note = "Test 123456"))
}
private val gson = Gson()
private val moshi = NetworkModule.providesMoshi()
@Test
fun `two equal status view data - should be equal`() {
@ -90,6 +91,7 @@ class StatusComparisonTest {
assertNotEquals(viewdata1, viewdata2)
}
@OptIn(ExperimentalStdlibApi::class)
private fun createStatus(
id: String = "123456",
content: String = """
@ -201,6 +203,6 @@ class StatusComparisonTest {
"poll": null
}
""".trimIndent()
return gson.fromJson(statusJson, Status::class.java)
return moshi.adapter<Status>().fromJson(statusJson)!!
}
}

View file

@ -15,7 +15,7 @@
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.compose.ComposeActivity
package com.keylesspalace.tusky.components.compose
import android.content.Intent
import android.os.Looper.getMainLooper
@ -23,8 +23,6 @@ import android.widget.EditText
import androidx.test.ext.junit.runners.AndroidJUnit4
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
@ -32,11 +30,20 @@ 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.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.network.MastodonApi
import com.squareup.moshi.adapter
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@ -44,6 +51,7 @@ import org.junit.Before
import org.junit.Test
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
@ -51,7 +59,8 @@ import org.robolectric.Robolectric
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.fakes.RoboMenuItem
import java.util.Locale
import retrofit2.HttpException
import retrofit2.Response
/**
* Created by charlag on 3/7/18.
@ -87,8 +96,10 @@ class ComposeActivityTest {
notificationVibration = true,
notificationLight = true
)
private var instanceV1ResponseCallback: (() -> InstanceV1)? = null
private var instanceResponseCallback: (() -> Instance)? = null
private var composeOptions: ComposeActivity.ComposeOptions? = null
private val moshi = NetworkModule.providesMoshi()
@Before
fun setupActivity() {
@ -102,17 +113,27 @@ class ComposeActivityTest {
apiMock = mock {
onBlocking { getCustomEmojis() } doReturn NetworkResult.success(emptyList())
onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance ->
if (instance == null) {
NetworkResult.failure(HttpException(Response.error<ResponseBody>(404, "Not found".toResponseBody())))
} else {
NetworkResult.success(instance)
}
}
onBlocking { getInstanceV1() } doReturn instanceV1ResponseCallback?.invoke().let { instance ->
if (instance == null) {
NetworkResult.failure(Throwable())
} else {
NetworkResult.success(instance)
}
}
onBlocking { search(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn NetworkResult.success(
SearchResult(emptyList(), emptyList(), emptyList())
)
}
val instanceDaoMock: InstanceDao = mock {
onBlocking { getInstanceInfo(any()) } doReturn
InstanceInfoEntity(instanceDomain, null, null, null, null, null, null, null, null, null, null, null, null, null, null)
InstanceInfoEntity(instanceDomain, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null)
onBlocking { getEmojiInfo(any()) } doReturn
EmojisEntity(instanceDomain, emptyList())
}
@ -121,7 +142,7 @@ class ComposeActivityTest {
on { instanceDao() } doReturn instanceDaoMock
}
val instanceInfoRepo = InstanceInfoRepository(apiMock, dbMock, accountManagerMock)
val instanceInfoRepo = InstanceInfoRepository(apiMock, dbMock, accountManagerMock, CoroutineScope(SupervisorJob()))
val viewModel = ComposeViewModel(
apiMock,
@ -192,22 +213,13 @@ class ComposeActivityTest {
@Test
fun whenMaximumTootCharsIsNull_defaultLimitIsUsed() {
instanceResponseCallback = { getInstanceWithCustomConfiguration(null) }
instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(null) }
setupActivity()
assertEquals(InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT, activity.maximumTootCharacters)
}
@Test
fun whenMaximumTootCharsIsPopulated_customLimitIsUsed() {
val customMaximum = 1000
instanceResponseCallback = { getInstanceWithCustomConfiguration(customMaximum, getCustomInstanceConfiguration(maximumStatusCharacters = customMaximum)) }
setupActivity()
shadowOf(getMainLooper()).idle()
assertEquals(customMaximum, activity.maximumTootCharacters)
}
@Test
fun whenOnlyLegacyMaximumTootCharsIsPopulated_customLimitIsUsed() {
val customMaximum = 1000
instanceResponseCallback = { getInstanceWithCustomConfiguration(customMaximum) }
setupActivity()
@ -215,10 +227,19 @@ class ComposeActivityTest {
assertEquals(customMaximum, activity.maximumTootCharacters)
}
@Test
fun whenOnlyLegacyMaximumTootCharsIsPopulated_customLimitIsUsed() {
val customMaximum = 1000
instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(customMaximum) }
setupActivity()
shadowOf(getMainLooper()).idle()
assertEquals(customMaximum, activity.maximumTootCharacters)
}
@Test
fun whenOnlyConfigurationMaximumTootCharsIsPopulated_customLimitIsUsed() {
val customMaximum = 1000
instanceResponseCallback = { getInstanceWithCustomConfiguration(null, getCustomInstanceConfiguration(maximumStatusCharacters = customMaximum)) }
instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(null, getCustomInstanceConfiguration(maximumStatusCharacters = customMaximum)) }
setupActivity()
shadowOf(getMainLooper()).idle()
assertEquals(customMaximum, activity.maximumTootCharacters)
@ -227,7 +248,7 @@ class ComposeActivityTest {
@Test
fun whenDifferentCharLimitsArePopulated_statusConfigurationLimitIsUsed() {
val customMaximum = 1000
instanceResponseCallback = { getInstanceWithCustomConfiguration(customMaximum, getCustomInstanceConfiguration(maximumStatusCharacters = customMaximum * 2)) }
instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(customMaximum, getCustomInstanceConfiguration(maximumStatusCharacters = customMaximum * 2)) }
setupActivity()
shadowOf(getMainLooper()).idle()
assertEquals(customMaximum * 2, activity.maximumTootCharacters)
@ -237,7 +258,21 @@ class ComposeActivityTest {
fun whenTextContainsNoUrl_everyCharacterIsCounted() {
val content = "This is test content please ignore thx "
insertSomeTextInContent(content)
assertEquals(activity.calculateTextLength(), content.length)
assertEquals(content.length, activity.calculateTextLength())
}
@Test
fun whenTextContainsEmoji_emojisAreCountedAsOneCharacter() {
val content = "Test 😜"
insertSomeTextInContent(content)
assertEquals(6, activity.calculateTextLength())
}
@Test
fun whenTextContainsUrlWithEmoji_ellipsizedUrlIsCountedCorrectly() {
val content = "https://🤪.com"
insertSomeTextInContent(content)
assertEquals(InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL, activity.calculateTextLength())
}
@Test
@ -245,7 +280,7 @@ class ComposeActivityTest {
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(activity.calculateTextLength(), additionalContent.length + InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL)
assertEquals(additionalContent.length + InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL, activity.calculateTextLength())
}
@Test
@ -254,7 +289,7 @@ class ComposeActivityTest {
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(activity.calculateTextLength(), additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2))
assertEquals(additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2), activity.calculateTextLength())
}
@Test
@ -262,7 +297,7 @@ class ComposeActivityTest {
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(activity.calculateTextLength(), additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2))
assertEquals(additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2), activity.calculateTextLength())
}
@Test
@ -270,11 +305,23 @@ class ComposeActivityTest {
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(configuration = getCustomInstanceConfiguration(charactersReservedPerUrl = customUrlLength)) }
instanceResponseCallback = { getInstanceWithCustomConfiguration(null, customUrlLength) }
setupActivity()
shadowOf(getMainLooper()).idle()
insertSomeTextInContent(additionalContent + url)
assertEquals(activity.calculateTextLength(), additionalContent.length + customUrlLength)
assertEquals(additionalContent.length + customUrlLength, activity.calculateTextLength())
}
@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 additionalContent = "Check out this @image #search result: "
val customUrlLength = 16
instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(configuration = getCustomInstanceConfiguration(charactersReservedPerUrl = customUrlLength)) }
setupActivity()
shadowOf(getMainLooper()).idle()
insertSomeTextInContent(additionalContent + url)
assertEquals(additionalContent.length + customUrlLength, activity.calculateTextLength())
}
@Test
@ -283,11 +330,24 @@ class ComposeActivityTest {
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(configuration = getCustomInstanceConfiguration(charactersReservedPerUrl = customUrlLength)) }
instanceResponseCallback = { getInstanceWithCustomConfiguration(null, customUrlLength) }
setupActivity()
shadowOf(getMainLooper()).idle()
insertSomeTextInContent(shortUrl + additionalContent + url)
assertEquals(activity.calculateTextLength(), additionalContent.length + (customUrlLength * 2))
assertEquals(additionalContent.length + (customUrlLength * 2), activity.calculateTextLength())
}
@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 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)) }
setupActivity()
shadowOf(getMainLooper()).idle()
insertSomeTextInContent(shortUrl + additionalContent + url)
assertEquals(additionalContent.length + (customUrlLength * 2), activity.calculateTextLength())
}
@Test
@ -295,11 +355,23 @@ class ComposeActivityTest {
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(configuration = getCustomInstanceConfiguration(charactersReservedPerUrl = customUrlLength)) }
instanceResponseCallback = { getInstanceWithCustomConfiguration(null, customUrlLength) }
setupActivity()
shadowOf(getMainLooper()).idle()
insertSomeTextInContent(url + additionalContent + url)
assertEquals(activity.calculateTextLength(), additionalContent.length + (customUrlLength * 2))
assertEquals(additionalContent.length + (customUrlLength * 2), activity.calculateTextLength())
}
@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 additionalContent = " Check out this @image #search result: "
val customUrlLength = 16
instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(configuration = getCustomInstanceConfiguration(charactersReservedPerUrl = customUrlLength)) }
setupActivity()
shadowOf(getMainLooper()).idle()
insertSomeTextInContent(url + additionalContent + url)
assertEquals(additionalContent.length + (customUrlLength * 2), activity.calculateTextLength())
}
@Test
@ -478,6 +550,15 @@ class ComposeActivityTest {
assertEquals(language, activity.selectedLanguage)
}
@Test
fun sampleFriendicaInstanceResponseIsDeserializable() {
// https://github.com/tuskyapp/Tusky/issues/4100
instanceResponseCallback = { getSampleFriendicaInstance() }
setupActivity()
shadowOf(getMainLooper()).idle()
assertEquals(FRIENDICA_MAXIMUM, activity.maximumTootCharacters)
}
private fun clickUp() {
val menuItem = RoboMenuItem(android.R.id.home)
activity.onOptionsItemSelected(menuItem)
@ -491,8 +572,33 @@ class ComposeActivityTest {
activity.findViewById<EditText>(R.id.composeEditField).setText(text ?: "Some text")
}
private fun getInstanceWithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): Instance {
private fun getInstanceWithCustomConfiguration(maximumStatusCharacters: Int? = null, charactersReservedPerUrl: Int? = null): Instance {
return Instance(
domain = "https://example.token",
version = "2.6.3",
configuration = getConfiguration(maximumStatusCharacters, charactersReservedPerUrl),
pleroma = null,
rules = emptyList()
)
}
private fun getConfiguration(maximumStatusCharacters: Int?, charactersReservedPerUrl: Int?): Instance.Configuration {
return Instance.Configuration(
Instance.Configuration.Urls(),
Instance.Configuration.Accounts(1),
Instance.Configuration.Statuses(
maximumStatusCharacters ?: InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT,
InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS,
charactersReservedPerUrl ?: InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL
),
Instance.Configuration.MediaAttachments(0, 0, 0, 0, 0),
Instance.Configuration.Polls(0, 0, 0, 0),
Instance.Configuration.Translation(false)
)
}
private fun getInstanceV1WithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): InstanceV1 {
return InstanceV1(
uri = "https://example.token",
version = "2.6.3",
maxTootChars = maximumLegacyTootCharacters,
@ -516,4 +622,84 @@ class ComposeActivityTest {
polls = null
)
}
@OptIn(ExperimentalStdlibApi::class)
private fun getSampleFriendicaInstance(): Instance {
return moshi.adapter<Instance>().fromJson(sampleFriendicaResponse)!!
}
companion object {
private const val FRIENDICA_MAXIMUM = 200000
// https://github.com/tuskyapp/Tusky/issues/4100
private val sampleFriendicaResponse = """{
"domain": "loma.ml",
"title": "[ˈloma]",
"version": "2.8.0 (compatible; Friendica 2023.09-rc)",
"source_url": "https://git.friendi.ca/friendica/friendica",
"description": "loma.ml ist eine Friendica Community im Fediverse auf der vorwiegend DE \uD83C\uDDE9\uD83C\uDDEA gesprochen wird. \\r\\nServer in Germany/EU \uD83C\uDDE9\uD83C\uDDEA \uD83C\uDDEA\uD83C\uDDFA. Open to all with fun in new. \\r\\nServer in Deutschland. Offen für alle mit Spaß an Neuen.",
"usage": {
"users": {
"active_month": 125
}
},
"thumbnail": {
"url": "https://loma.ml/ad/friendica-banner.jpg"
},
"languages": [
"de"
],
"configuration": {
"statuses": {
"max_characters": $FRIENDICA_MAXIMUM
},
"media_attachments": {
"supported_mime_types": {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif"
},
"image_size_limit": 10485760
}
},
"registrations": {
"enabled": true,
"approval_required": false
},
"contact": {
"email": "anony@miz.ed",
"account": {
"id": "9632",
"username": "webm",
"acct": "webm",
"display_name": "web m \uD83C\uDDEA\uD83C\uDDFA",
"locked": false,
"bot": false,
"discoverable": true,
"group": false,
"created_at": "2018-05-21T11:24:55.000Z",
"note": "\uD83C\uDDE9\uD83C\uDDEA Über diesen Account werden Änderungen oder geplante Beeinträchtigungen angekündigt. Wenn du einen Account auf Loma.ml besitzt, dann solltest du dich mit mir verbinden.\uD83C\uDDEA\uD83C\uDDFA Changes or planned impairments are announced via this account. If you have an account on Loma.ml, you should connect to me.\uD83C\uDD98 Fallbackaccount @webm@joinfriendica.de",
"url": "https://loma.ml/profile/webm",
"avatar": "https://loma.ml/photo/contact/320/373ebf56355ac895a09cb99264485383?ts=1686417730",
"avatar_static": "https://loma.ml/photo/contact/320/373ebf56355ac895a09cb99264485383?ts=1686417730&static=1",
"header": "https://loma.ml/photo/header/373ebf56355ac895a09cb99264485383?ts=1686417730",
"header_static": "https://loma.ml/photo/header/373ebf56355ac895a09cb99264485383?ts=1686417730&static=1",
"followers_count": 23,
"following_count": 25,
"statuses_count": 15,
"last_status_at": "2023-09-19T00:00:00.000Z",
"emojis": [],
"fields": []
}
},
"rules": [],
"friendica": {
"version": "2023.09-rc",
"codename": "Giant Rhubarb",
"db_version": 1539
}
}
""".trimIndent()
}
}

View file

@ -13,9 +13,8 @@
* 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.components.compose.ComposeTokenizer
package com.keylesspalace.tusky.components.compose
import com.keylesspalace.tusky.components.compose.ComposeTokenizer
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith

View file

@ -15,29 +15,31 @@
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.compose.ComposeActivity
package com.keylesspalace.tusky.components.compose
import com.keylesspalace.tusky.SpanUtilsTest
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.util.highlightSpans
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(Parameterized::class)
@RunWith(ParameterizedRobolectricTestRunner::class)
@Config(sdk = [33])
class StatusLengthTest(
private val text: String,
private val expectedLength: Int
) {
companion object {
@Parameterized.Parameters(name = "{0}")
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
@JvmStatic
fun data(): Iterable<Any> {
return listOf(
arrayOf("", 0),
arrayOf(" ", 1),
arrayOf("123", 3),
arrayOf("🫣", 1),
// "@user@server" should be treated as "@user"
arrayOf("123 @example@example.org", 12),
// URLs under 23 chars are treated as 23 chars

View file

@ -1,145 +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.components.notifications
import androidx.paging.PagingSource
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.gson.Gson
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.robolectric.annotation.Config
import retrofit2.Response
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsPagingSourceTest {
@Test
fun `load() returns error message on HTTP error`() = runTest {
// Given
val jsonError = "{error: 'This is an error'}".toResponseBody()
val mockApi: MastodonApi = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
}
val filter = emptySet<Notification.Type>()
val gson = Gson()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When
val loadResult = pagingSource.load(loadingParams)
// Then
assertTrue(loadResult is PagingSource.LoadResult.Error)
assertEquals(
"HTTP 429: This is an error",
(loadResult as PagingSource.LoadResult.Error).throwable.message
)
}
// As previous, but with `error_description` field as well.
@Test
fun `load() returns extended error message on HTTP error`() = runTest {
// Given
val jsonError = "{error: 'This is an error', error_description: 'Description of the error'}".toResponseBody()
val mockApi: MastodonApi = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
}
val filter = emptySet<Notification.Type>()
val gson = Gson()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When
val loadResult = pagingSource.load(loadingParams)
// Then
assertTrue(loadResult is PagingSource.LoadResult.Error)
assertEquals(
"HTTP 429: This is an error: Description of the error",
(loadResult as PagingSource.LoadResult.Error).throwable.message
)
}
// As previous, but no error JSON, so expect default response
@Test
fun `load() returns default error message on empty HTTP error`() = runTest {
// Given
val jsonError = "{}".toResponseBody()
val mockApi: MastodonApi = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
}
val filter = emptySet<Notification.Type>()
val gson = Gson()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When
val loadResult = pagingSource.load(loadingParams)
// Then
assertTrue(loadResult is PagingSource.LoadResult.Error)
assertEquals(
"HTTP 429: no reason given",
(loadResult as PagingSource.LoadResult.Error).throwable.message
)
}
// As previous, but malformed JSON, so expect response with enough information to troubleshoot
@Test
fun `load() returns useful error message on malformed HTTP error`() = runTest {
// Given
val jsonError = "{'malformedjson}".toResponseBody()
val mockApi: MastodonApi = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
}
val filter = emptySet<Notification.Type>()
val gson = Gson()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When
val loadResult = pagingSource.load(loadingParams)
// Then
assertTrue(loadResult is PagingSource.LoadResult.Error)
assertEquals(
"HTTP 429: {'malformedjson} (com.google.gson.JsonSyntaxException: com.google.gson.stream.MalformedJsonException: Unterminated string at line 1 column 17 path \$.)",
(loadResult as PagingSource.LoadResult.Error).throwable.message
)
}
}

View file

@ -1,137 +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.components.notifications
import android.content.SharedPreferences
import android.os.Looper
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.TimelineCases
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Before
import org.junit.Rule
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import retrofit2.HttpException
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class MainCoroutineRule constructor(private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()) : TestWatcher() {
override fun starting(description: Description) {
super.starting(description)
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
super.finished(description)
Dispatchers.resetMain()
}
}
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalCoroutinesApi::class)
abstract class NotificationsViewModelTestBase {
protected lateinit var notificationsRepository: NotificationsRepository
protected lateinit var sharedPreferencesMap: MutableMap<String, Boolean>
protected lateinit var sharedPreferences: SharedPreferences
protected lateinit var accountManager: AccountManager
protected lateinit var timelineCases: TimelineCases
protected lateinit var eventHub: EventHub
protected lateinit var viewModel: NotificationsViewModel
/** Empty success response, for API calls that return one */
protected var emptySuccess = Response.success("".toResponseBody())
/** Empty error response, for API calls that return one */
protected var emptyError: Response<ResponseBody> = Response.error(404, "".toResponseBody())
/** Exception to throw when testing errors */
protected val httpException = HttpException(emptyError)
@get:Rule
val mainCoroutineRule = MainCoroutineRule()
@Before
fun setup() {
shadowOf(Looper.getMainLooper()).idle()
notificationsRepository = mock()
// Backing store for sharedPreferences, to allow mutation in tests
sharedPreferencesMap = mutableMapOf(
PrefKeys.ANIMATE_GIF_AVATARS to false,
PrefKeys.ANIMATE_CUSTOM_EMOJIS to false,
PrefKeys.ABSOLUTE_TIME_VIEW to false,
PrefKeys.SHOW_BOT_OVERLAY to true,
PrefKeys.USE_BLURHASH to true,
PrefKeys.CONFIRM_REBLOGS to true,
PrefKeys.CONFIRM_FAVOURITES to false,
PrefKeys.WELLBEING_HIDE_STATS_POSTS to false,
PrefKeys.SHOW_NOTIFICATIONS_FILTER to true,
PrefKeys.FAB_HIDE to false
)
// Any getBoolean() call looks for the result in sharedPreferencesMap
sharedPreferences = mock {
on { getBoolean(any(), any()) } doAnswer { sharedPreferencesMap[it.arguments[0]] }
}
accountManager = mock {
on { activeAccount } doReturn AccountEntity(
id = 1,
domain = "mastodon.test",
accessToken = "fakeToken",
clientId = "fakeId",
clientSecret = "fakeSecret",
isActive = true,
notificationsFilter = "['follow']",
mediaPreviewEnabled = true,
alwaysShowSensitiveMedia = true,
alwaysOpenSpoiler = true
)
}
eventHub = EventHub()
timelineCases = mock()
viewModel = NotificationsViewModel(
notificationsRepository,
sharedPreferences,
accountManager,
timelineCases,
eventHub
)
}
}

View file

@ -1,65 +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.components.notifications
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.stub
import org.mockito.kotlin.verify
/**
* Verify that [ClearNotifications] is handled correctly on receipt:
*
* - Is the correct [UiSuccess] or [UiError] value emitted?
* - Are the correct [NotificationsRepository] functions called, with the correct arguments?
* This is only tested in the success case; if it passed there it must also
* have passed in the error case.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModelTestClearNotifications : NotificationsViewModelTestBase() {
@Test
fun `clearing notifications succeeds && invalidate the repository`() = runTest {
// Given
notificationsRepository.stub { onBlocking { clearNotifications() } doReturn emptySuccess }
// When
viewModel.accept(FallibleUiAction.ClearNotifications)
// Then
verify(notificationsRepository).clearNotifications()
verify(notificationsRepository).invalidate()
}
@Test
fun `clearing notifications fails && emits UiError`() = runTest {
// Given
notificationsRepository.stub { onBlocking { clearNotifications() } doReturn emptyError }
viewModel.uiError.test {
// When
viewModel.accept(FallibleUiAction.ClearNotifications)
// Then
assertThat(awaitItem()).isInstanceOf(UiError::class.java)
}
}
}

View file

@ -1,66 +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.components.notifications
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.entity.Notification
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.verify
/**
* Verify that [ApplyFilter] is handled correctly on receipt:
*
* - Is the [UiState] updated correctly?
* - Are the correct [AccountManager] functions called, with the correct arguments?
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModelTestFilter : NotificationsViewModelTestBase() {
@Test
fun `should load initial filter from active account`() = runTest {
viewModel.uiState.test {
assertThat(awaitItem().activeFilter)
.containsExactlyElementsIn(setOf(Notification.Type.FOLLOW))
}
}
@Test
fun `should save filter to active account && update state`() = runTest {
viewModel.uiState.test {
// When
viewModel.accept(InfallibleUiAction.ApplyFilter(setOf(Notification.Type.REBLOG)))
// Then
// - filter saved to active account
argumentCaptor<AccountEntity>().apply {
verify(accountManager).saveAccount(capture())
assertThat(this.lastValue.notificationsFilter)
.isEqualTo("[\"reblog\"]")
}
// - filter updated in uiState
assertThat(expectMostRecentItem().activeFilter)
.containsExactlyElementsIn(setOf(Notification.Type.REBLOG))
}
}
}

View file

@ -1,144 +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.components.notifications
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.entity.Relationship
import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.stub
import org.mockito.kotlin.verify
/**
* Verify that [NotificationAction] are handled correctly on receipt:
*
* - Is the correct [UiSuccess] or [UiError] value emitted?
* - Is the correct [TimelineCases] function called, with the correct arguments?
* This is only tested in the success case; if it passed there it must also
* have passed in the error case.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModelTestNotificationAction : NotificationsViewModelTestBase() {
/** Dummy relationship */
private val relationship = Relationship(
// Nothing special about these values, it's just to have something to return
"1234",
following = true,
followedBy = true,
blocking = false,
muting = false,
mutingNotifications = false,
requested = false,
showingReblogs = false,
subscribing = null,
blockingDomain = false,
note = null,
notifying = null
)
/** Action to accept a follow request */
private val acceptAction = NotificationAction.AcceptFollowRequest("1234")
/** Action to reject a follow request */
private val rejectAction = NotificationAction.RejectFollowRequest("1234")
@Test
fun `accepting follow request succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub {
onBlocking { acceptFollowRequest(any()) } doReturn Single.just(relationship)
}
viewModel.uiSuccess.test {
// When
viewModel.accept(acceptAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(NotificationActionSuccess::class.java)
assertThat((item as NotificationActionSuccess).action).isEqualTo(acceptAction)
}
// Then
argumentCaptor<String>().apply {
verify(timelineCases).acceptFollowRequest(capture())
assertThat(this.lastValue).isEqualTo("1234")
}
}
@Test
fun `accepting follow request fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { acceptFollowRequest(any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(acceptAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.AcceptFollowRequest::class.java)
assertThat(item.action).isEqualTo(acceptAction)
}
}
@Test
fun `rejecting follow request succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub { onBlocking { rejectFollowRequest(any()) } doReturn Single.just(relationship) }
viewModel.uiSuccess.test {
// When
viewModel.accept(rejectAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(NotificationActionSuccess::class.java)
assertThat((item as NotificationActionSuccess).action).isEqualTo(rejectAction)
}
// Then
argumentCaptor<String>().apply {
verify(timelineCases).rejectFollowRequest(capture())
assertThat(this.lastValue).isEqualTo("1234")
}
}
@Test
fun `rejecting follow request fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { rejectFollowRequest(any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(rejectAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.RejectFollowRequest::class.java)
assertThat(item.action).isEqualTo(rejectAction)
}
}
}

View file

@ -1,227 +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.components.notifications
import app.cash.turbine.test
import at.connyduck.calladapter.networkresult.NetworkResult
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.FilterV1Test.Companion.mockStatus
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.stub
import org.mockito.kotlin.verify
/**
* Verify that [StatusAction] are handled correctly on receipt:
*
* - Is the correct [UiSuccess] or [UiError] value emitted?
* - Is the correct [TimelineCases] function called, with the correct arguments?
* This is only tested in the success case; if it passed there it must also
* have passed in the error case.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModelTestStatusAction : NotificationsViewModelTestBase() {
private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3"))
private val statusViewData = StatusViewData.Concrete(
status = status,
isExpanded = true,
isShowingContent = false,
isCollapsed = false
)
/** Action to bookmark a status */
private val bookmarkAction = StatusAction.Bookmark(true, statusViewData)
/** Action to favourite a status */
private val favouriteAction = StatusAction.Favourite(true, statusViewData)
/** Action to reblog a status */
private val reblogAction = StatusAction.Reblog(true, statusViewData)
/** Action to vote in a poll */
private val voteInPollAction = StatusAction.VoteInPoll(
poll = status.poll!!,
choices = listOf(1, 0, 0),
statusViewData
)
/** Captors for status ID and state arguments */
private val id = argumentCaptor<String>()
private val state = argumentCaptor<Boolean>()
@Test
fun `bookmark succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) }
viewModel.uiSuccess.test {
// When
viewModel.accept(bookmarkAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(StatusActionSuccess.Bookmark::class.java)
assertThat((item as StatusActionSuccess).action).isEqualTo(bookmarkAction)
}
// Then
verify(timelineCases).bookmark(id.capture(), state.capture())
assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
assertThat(state.firstValue).isEqualTo(true)
}
@Test
fun `bookmark fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(bookmarkAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.Bookmark::class.java)
assertThat(item.action).isEqualTo(bookmarkAction)
}
}
@Test
fun `favourite succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub {
onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status)
}
viewModel.uiSuccess.test {
// When
viewModel.accept(favouriteAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(StatusActionSuccess.Favourite::class.java)
assertThat((item as StatusActionSuccess).action).isEqualTo(favouriteAction)
}
// Then
verify(timelineCases).favourite(id.capture(), state.capture())
assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
assertThat(state.firstValue).isEqualTo(true)
}
@Test
fun `favourite fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(favouriteAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.Favourite::class.java)
assertThat(item.action).isEqualTo(favouriteAction)
}
}
@Test
fun `reblog succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) }
viewModel.uiSuccess.test {
// When
viewModel.accept(reblogAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(StatusActionSuccess.Reblog::class.java)
assertThat((item as StatusActionSuccess).action).isEqualTo(reblogAction)
}
// Then
verify(timelineCases).reblog(id.capture(), state.capture())
assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
assertThat(state.firstValue).isEqualTo(true)
}
@Test
fun `reblog fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(reblogAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.Reblog::class.java)
assertThat(item.action).isEqualTo(reblogAction)
}
}
@Test
fun `voteinpoll succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub {
onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!)
}
viewModel.uiSuccess.test {
// When
viewModel.accept(voteInPollAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(StatusActionSuccess.VoteInPoll::class.java)
assertThat((item as StatusActionSuccess).action).isEqualTo(voteInPollAction)
}
// Then
val pollId = argumentCaptor<String>()
val choices = argumentCaptor<List<Int>>()
verify(timelineCases).voteInPoll(id.capture(), pollId.capture(), choices.capture())
assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
assertThat(pollId.firstValue).isEqualTo(status.poll!!.id)
assertThat(choices.firstValue).isEqualTo(voteInPollAction.choices)
}
@Test
fun `voteinpoll fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(voteInPollAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.VoteInPoll::class.java)
assertThat(item.action).isEqualTo(voteInPollAction)
}
}
}

View file

@ -1,103 +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.components.notifications
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.StatusDisplayOptions
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
* Verify that [StatusDisplayOptions] are handled correctly.
*
* - Is the initial value taken from values in sharedPreferences and account?
* - Does the make() function correctly use an updated preference?
* - Is the correct update emitted when a relevant preference changes?
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModelTestStatusDisplayOptions : NotificationsViewModelTestBase() {
private val defaultStatusDisplayOptions = StatusDisplayOptions(
animateAvatars = false,
mediaPreviewEnabled = true, // setting in NotificationsViewModelTestBase
useAbsoluteTime = false,
showBotOverlay = true,
useBlurhash = true,
cardViewMode = CardViewMode.NONE,
confirmReblogs = true,
confirmFavourites = false,
hideStats = false,
animateEmojis = false,
showStatsInline = false,
showSensitiveMedia = true, // setting in NotificationsViewModelTestBase
openSpoiler = true // setting in NotificationsViewModelTestBase
)
@Test
fun `initial settings are from sharedPreferences and activeAccount`() = runTest {
viewModel.statusDisplayOptions.test {
val item = awaitItem()
assertThat(item).isEqualTo(defaultStatusDisplayOptions)
}
}
@Test
fun `make() uses updated preference`() = runTest {
// Prior, should be false
assertThat(defaultStatusDisplayOptions.animateAvatars).isFalse()
// Given; just a change to one preferences
sharedPreferencesMap[PrefKeys.ANIMATE_GIF_AVATARS] = true
// When
val updatedOptions = defaultStatusDisplayOptions.make(
sharedPreferences,
PrefKeys.ANIMATE_GIF_AVATARS,
accountManager.activeAccount!!
)
// Then, should be true
assertThat(updatedOptions.animateAvatars).isTrue()
}
@Test
fun `PreferenceChangedEvent emits new StatusDisplayOptions`() = runTest {
// Prior, should be false
viewModel.statusDisplayOptions.test {
val item = expectMostRecentItem()
assertThat(item.animateAvatars).isFalse()
}
// Given
sharedPreferencesMap[PrefKeys.ANIMATE_GIF_AVATARS] = true
// When
eventHub.dispatch(PreferenceChangedEvent(PrefKeys.ANIMATE_GIF_AVATARS))
// Then, should be true
viewModel.statusDisplayOptions.test {
val item = expectMostRecentItem()
assertThat(item.animateAvatars).isTrue()
}
}
}

View file

@ -1,88 +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.components.notifications
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.settings.PrefKeys
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
* Verify that [UiState] is handled correctly.
*
* - Is the initial value taken from values in sharedPreferences and account?
* - Is the correct update emitted when a relevant preference changes?
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModelTestUiState : NotificationsViewModelTestBase() {
private val initialUiState = UiState(
activeFilter = setOf(Notification.Type.FOLLOW),
showFilterOptions = true,
showFabWhileScrolling = true
)
@Test
fun `should load initial filter from active account`() = runTest {
viewModel.uiState.test {
assertThat(expectMostRecentItem()).isEqualTo(initialUiState)
}
}
@Test
fun `showFabWhileScrolling depends on FAB_HIDE preference`() = runTest {
// Prior
viewModel.uiState.test {
assertThat(expectMostRecentItem().showFabWhileScrolling).isTrue()
}
// Given
sharedPreferencesMap[PrefKeys.FAB_HIDE] = true
// When
eventHub.dispatch(PreferenceChangedEvent(PrefKeys.FAB_HIDE))
// Then
viewModel.uiState.test {
assertThat(expectMostRecentItem().showFabWhileScrolling).isFalse()
}
}
@Test
fun `showFilterOptions depends on SHOW_NOTIFICATIONS_FILTER preference`() = runTest {
// Prior
viewModel.uiState.test {
assertThat(expectMostRecentItem().showFilterOptions).isTrue()
}
// Given
sharedPreferencesMap[PrefKeys.SHOW_NOTIFICATIONS_FILTER] = false
// When
eventHub.dispatch(PreferenceChangedEvent(PrefKeys.SHOW_NOTIFICATIONS_FILTER))
// Then
viewModel.uiState.test {
assertThat(expectMostRecentItem().showFilterOptions).isFalse()
}
}
}

View file

@ -1,43 +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.components.notifications
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.db.AccountEntity
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.verify
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModelTestVisibleId : NotificationsViewModelTestBase() {
@Test
fun `should save notification ID to active account`() = runTest {
argumentCaptor<AccountEntity>().apply {
// When
viewModel.accept(InfallibleUiAction.SaveVisibleId("1234"))
// Then
verify(accountManager).saveAccount(capture())
assertThat(this.lastValue.lastNotificationId)
.isEqualTo("1234")
}
}
}

View file

@ -10,13 +10,14 @@ import androidx.paging.RemoteMediator
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.gson.Gson
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineRemoteMediator
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.di.NetworkModule
import java.io.IOException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import okhttp3.ResponseBody.Companion.toResponseBody
@ -35,7 +36,6 @@ import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
@ -54,6 +54,8 @@ class CachedTimelineRemoteMediatorTest {
private lateinit var db: AppDatabase
private val moshi = NetworkModule.providesMoshi()
@Before
@ExperimentalCoroutinesApi
fun setup() {
@ -61,7 +63,7 @@ class CachedTimelineRemoteMediatorTest {
val context = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.addTypeConverter(Converters(Gson()))
.addTypeConverter(Converters(moshi))
.build()
}
@ -80,7 +82,7 @@ class CachedTimelineRemoteMediatorTest {
onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody())
},
db = db,
gson = Gson()
moshi = moshi
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) }
@ -99,7 +101,7 @@ class CachedTimelineRemoteMediatorTest {
onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException()
},
db = db,
gson = Gson()
moshi = moshi
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) }
@ -115,7 +117,7 @@ class CachedTimelineRemoteMediatorTest {
accountManager = accountManager,
api = mock(),
db = db,
gson = Gson()
moshi = moshi
)
val state = state(
@ -166,7 +168,7 @@ class CachedTimelineRemoteMediatorTest {
)
},
db = db,
gson = Gson()
moshi = moshi
)
val state = state(
@ -229,7 +231,7 @@ class CachedTimelineRemoteMediatorTest {
)
},
db = db,
gson = Gson()
moshi = moshi
)
val state = state(
@ -289,7 +291,7 @@ class CachedTimelineRemoteMediatorTest {
)
},
db = db,
gson = Gson()
moshi = moshi
)
val state = state(
@ -334,7 +336,7 @@ class CachedTimelineRemoteMediatorTest {
)
},
db = db,
gson = Gson()
moshi = moshi
)
val state = state(
@ -385,7 +387,7 @@ class CachedTimelineRemoteMediatorTest {
)
},
db = db,
gson = Gson()
moshi = moshi
)
val state = state(
@ -441,7 +443,7 @@ class CachedTimelineRemoteMediatorTest {
)
},
db = db,
gson = Gson()
moshi = moshi
)
val state = state(
@ -493,7 +495,7 @@ class CachedTimelineRemoteMediatorTest {
)
},
db = db,
gson = Gson()
moshi = moshi
)
val state = state(

View file

@ -9,9 +9,11 @@ import androidx.paging.RemoteMediator
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.viewdata.StatusViewData
import java.io.IOException
import kotlinx.coroutines.runBlocking
import okhttp3.Headers
import okhttp3.ResponseBody.Companion.toResponseBody
@ -27,7 +29,6 @@ import org.mockito.kotlin.verify
import org.robolectric.annotation.Config
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
@Config(sdk = [29])
@RunWith(AndroidJUnit4::class)
@ -382,6 +383,59 @@ class NetworkTimelineRemoteMediatorTest {
assertEquals(newStatusData, statuses)
}
@Test
@ExperimentalPagingApi
fun `should not append duplicates for trending statuses`() {
val statuses: MutableList<StatusViewData> = mutableListOf(
mockStatusViewData("5"),
mockStatusViewData("4"),
mockStatusViewData("3")
)
val timelineViewModel: NetworkTimelineViewModel = mock {
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")
),
Headers.headersOf(
"Link",
"<https://mastodon.example/api/v1/trends/statuses?offset=5>; rel=\"next\""
)
)
}
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = statuses,
prevKey = null,
nextKey = "3"
)
)
)
val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
val newStatusData = mutableListOf(
mockStatusViewData("5"),
mockStatusViewData("4"),
mockStatusViewData("3"),
mockStatusViewData("2"),
mockStatusViewData("1")
)
verify(timelineViewModel).nextKey = "5"
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
assertEquals(newStatusData, statuses)
}
private fun state(pages: List<PagingSource.LoadResult.Page<String, StatusViewData>> = emptyList()) = PagingState(
pages = pages,
anchorPosition = null,

View file

@ -1,7 +1,7 @@
package com.keylesspalace.tusky.components.timeline
import com.google.gson.Gson
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
@ -54,7 +54,7 @@ fun mockStatus(
poll = null,
card = null,
language = null,
filtered = null
filtered = emptyList()
)
fun mockStatusViewData(
@ -91,19 +91,19 @@ fun mockStatusEntityWithAccount(
expanded: Boolean = false
): TimelineStatusWithAccount {
val mockedStatus = mockStatus(id)
val gson = Gson()
val moshi = NetworkModule.providesMoshi()
return TimelineStatusWithAccount(
status = mockedStatus.toEntity(
timelineUserId = userId,
gson = gson,
moshi = moshi,
expanded = expanded,
contentShowing = false,
contentCollapsed = true
),
account = mockedStatus.account.toEntity(
accountId = userId,
gson = gson
moshi = moshi
)
)
}

View file

@ -6,21 +6,20 @@ import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import at.connyduck.calladapter.networkresult.NetworkResult
import com.google.gson.Gson
import com.keylesspalace.tusky.appstore.BookmarkEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FavoriteEvent
import com.keylesspalace.tusky.appstore.ReblogEvent
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.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.di.NetworkModule
import com.keylesspalace.tusky.entity.StatusContext
import com.keylesspalace.tusky.network.FilterModel
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 org.junit.After
@ -34,7 +33,6 @@ import org.mockito.kotlin.mock
import org.mockito.kotlin.stub
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import java.io.IOException
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
@ -46,6 +44,7 @@ class ViewThreadViewModelTest {
private lateinit var db: AppDatabase
private val threadId = "1234"
private val moshi = NetworkModule.providesMoshi()
/**
* Execute each task synchronously.
@ -79,7 +78,9 @@ class ViewThreadViewModelTest {
fun setup() {
shadowOf(getMainLooper()).idle()
api = mock()
api = mock {
onBlocking { getFilters() } doReturn NetworkResult.success(emptyList())
}
eventHub = EventHub()
val filterModel = FilterModel()
val timelineCases = TimelineCases(api, eventHub)
@ -95,12 +96,11 @@ class ViewThreadViewModelTest {
}
val context = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.addTypeConverter(Converters(Gson()))
.addTypeConverter(Converters(moshi))
.allowMainThreadQueries()
.build()
val gson = Gson()
viewModel = ViewThreadViewModel(api, filterModel, timelineCases, eventHub, accountManager, db, gson)
viewModel = ViewThreadViewModel(api, filterModel, timelineCases, eventHub, accountManager, db, moshi)
}
@After
@ -216,13 +216,13 @@ class ViewThreadViewModelTest {
}
@Test
fun `should handle favorite event`() {
fun `should handle status changed event`() {
mockSuccessResponses()
viewModel.loadThread(threadId)
runBlocking {
eventHub.dispatch(FavoriteEvent(statusId = "1", false))
eventHub.dispatch(StatusChangedEvent(mockStatus(id = "1", spoilerText = "Test", favourited = false)))
assertEquals(
ThreadUiState.Success(
@ -239,54 +239,6 @@ class ViewThreadViewModelTest {
}
}
@Test
fun `should handle reblog event`() {
mockSuccessResponses()
viewModel.loadThread(threadId)
runBlocking {
eventHub.dispatch(ReblogEvent(statusId = "2", true))
assertEquals(
ThreadUiState.Success(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", reblogged = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
),
viewModel.uiState.first()
)
}
}
@Test
fun `should handle bookmark event`() {
mockSuccessResponses()
viewModel.loadThread(threadId)
runBlocking {
eventHub.dispatch(BookmarkEvent(statusId = "3", false))
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", bookmarked = false)
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
),
viewModel.uiState.first()
)
}
}
@Test
fun `should remove status`() {
mockSuccessResponses()

View file

@ -4,9 +4,9 @@ import androidx.paging.PagingSource
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.gson.Gson
import com.keylesspalace.tusky.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
@ -23,11 +23,13 @@ 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(Gson()))
.addTypeConverter(Converters(moshi))
.allowMainThreadQueries()
.build()
timelineDao = db.timelineDao()

View file

@ -1,13 +1,17 @@
package com.keylesspalace.tusky.json
import com.google.gson.Gson
import com.keylesspalace.tusky.entity.Relationship
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import org.junit.Assert.assertEquals
import org.junit.Test
class GuardedBooleanAdapterTest {
@OptIn(ExperimentalStdlibApi::class)
class GuardedAdapterTest {
private val gson = Gson()
private val moshi = Moshi.Builder()
.add(GuardedAdapter.ANNOTATION_FACTORY)
.build()
@Test
fun `should deserialize Relationship when attribute 'subscribing' is a boolean`() {
@ -45,7 +49,7 @@ class GuardedBooleanAdapterTest {
note = "Hi",
notifying = false
),
gson.fromJson(jsonInput, Relationship::class.java)
moshi.adapter<Relationship>().fromJson(jsonInput)
)
}
@ -85,7 +89,7 @@ class GuardedBooleanAdapterTest {
note = "Hi",
notifying = false
),
gson.fromJson(jsonInput, Relationship::class.java)
moshi.adapter<Relationship>().fromJson(jsonInput)
)
}
@ -124,7 +128,7 @@ class GuardedBooleanAdapterTest {
note = "Hi",
notifying = false
),
gson.fromJson(jsonInput, Relationship::class.java)
moshi.adapter<Relationship>().fromJson(jsonInput)
)
}
}

View file

@ -4,9 +4,10 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.test
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PinEvent
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 okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
@ -19,7 +20,6 @@ import org.mockito.kotlin.stub
import org.robolectric.annotation.Config
import retrofit2.HttpException
import retrofit2.Response
import java.util.*
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
@ -39,15 +39,17 @@ class TimelineCasesTest {
}
@Test
fun `pin success emits PinEvent`() {
fun `pin success emits StatusChangedEvent`() {
val pinnedStatus = mockStatus(pinned = true)
api.stub {
onBlocking { pinStatus(statusId) } doReturn NetworkResult.success(mockStatus(pinned = true))
onBlocking { pinStatus(statusId) } doReturn NetworkResult.success(pinnedStatus)
}
runBlocking {
eventHub.events.test {
timelineCases.pin(statusId, true)
assertEquals(PinEvent(statusId, true), awaitItem())
assertEquals(StatusChangedEvent(pinnedStatus), awaitItem())
}
}
}
@ -102,7 +104,7 @@ class TimelineCasesTest {
poll = null,
card = null,
language = null,
filtered = null
filtered = emptyList()
)
}
}

View file

@ -1,12 +1,35 @@
package com.keylesspalace.tusky.util
import org.junit.Assert.assertEquals
import org.junit.Test
import java.time.Instant
import java.util.Date
import java.util.TimeZone
import java.util.*
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.BeforeClass
import org.junit.Test
class AbsoluteTimeFormatterTest {
companion object {
/** Default locale before this test started */
private lateinit var locale: Locale
/**
* Ensure the Locale is ENGLISH so that tests against literal strings like
* "Apr" later, even if the test host's locale is e.g. FRENCH which would
* normally report "avr.".
*/
@BeforeClass
@JvmStatic
fun beforeClass() {
locale = Locale.getDefault()
Locale.setDefault(Locale.ENGLISH)
}
@AfterClass
@JvmStatic
fun afterClass() {
Locale.setDefault(locale)
}
}
private val formatter = AbsoluteTimeFormatter(TimeZone.getTimeZone("UTC"))
private val now = Date.from(Instant.parse("2022-04-11T00:00:00.00Z"))

View file

@ -19,14 +19,14 @@ 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
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class)
class FlowExtensionsTest {

View file

@ -1,8 +1,8 @@
package com.keylesspalace.tusky.util
import android.content.Context
import android.text.SpannableStringBuilder
import android.text.style.URLSpan
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.keylesspalace.tusky.R
@ -33,8 +33,8 @@ class LinkHelperTest {
HashTag("mastodev", "https://example.com/Tags/mastodev")
)
private val context: Context
get() = InstrumentationRegistry.getInstrumentation().targetContext
private val textView: TextView
get() = TextView(InstrumentationRegistry.getInstrumentation().targetContext)
@Test
fun whenSettingClickableText_mentionUrlsArePreserved() {
@ -168,8 +168,8 @@ class LinkHelperTest {
content.append(displayedContent, URLSpan(maliciousUrl), 0)
val oldContent = content.toString()
Assert.assertEquals(
context.getString(R.string.url_domain_notifier, displayedContent, maliciousDomain),
markupHiddenUrls(context, content).toString()
textView.context.getString(R.string.url_domain_notifier, displayedContent, maliciousDomain),
markupHiddenUrls(textView, content).toString()
)
Assert.assertEquals(oldContent, content.toString())
}
@ -182,8 +182,8 @@ class LinkHelperTest {
val content = SpannableStringBuilder()
content.append(displayedContent, URLSpan(maliciousUrl), 0)
Assert.assertEquals(
context.getString(R.string.url_domain_notifier, displayedContent, maliciousDomain),
markupHiddenUrls(context, content).toString()
textView.context.getString(R.string.url_domain_notifier, displayedContent, maliciousDomain),
markupHiddenUrls(textView, content).toString()
)
}
@ -196,9 +196,9 @@ class LinkHelperTest {
content.append(displayedContent, URLSpan("https://$domain/foo/bar"), 0)
}
val markedUpContent = markupHiddenUrls(context, content)
val markedUpContent = markupHiddenUrls(textView, content)
for (domain in domains) {
Assert.assertTrue(markedUpContent.contains(context.getString(R.string.url_domain_notifier, displayedContent, domain)))
Assert.assertTrue(markedUpContent.contains(textView.context.getString(R.string.url_domain_notifier, displayedContent, domain)))
}
}
@ -215,7 +215,7 @@ class LinkHelperTest {
.append("$domain/", URLSpan("https://$domain"), 0)
.append("$domain/", URLSpan("https://www.$domain"), 0)
val markedUpContent = markupHiddenUrls(context, content)
val markedUpContent = markupHiddenUrls(textView, content)
Assert.assertFalse(markedUpContent.contains("🔗"))
}
@ -228,7 +228,7 @@ class LinkHelperTest {
.append("Some Place | https://some.place/", URLSpan("https://some.place/"), 0)
.append("Some Place https://some.place/path", URLSpan("https://some.place/path"), 0)
val markedUpContent = markupHiddenUrls(context, content)
val markedUpContent = markupHiddenUrls(textView, content)
Assert.assertFalse(markedUpContent.contains("🔗"))
}
@ -241,7 +241,7 @@ class LinkHelperTest {
.append("Another Place | https://another.place/", URLSpan("https://some.place/"), 0)
.append("Another Place https://another.place/path", URLSpan("https://some.place/path"), 0)
val markedUpContent = markupHiddenUrls(context, content)
val markedUpContent = markupHiddenUrls(textView, content)
val asserts = listOf(
"Another Place: another.place",
"Another Place: another.place/",
@ -250,7 +250,7 @@ class LinkHelperTest {
"Another Place https://another.place/path"
)
asserts.forEach {
Assert.assertTrue(markedUpContent.contains(context.getString(R.string.url_domain_notifier, it, "some.place")))
Assert.assertTrue(markedUpContent.contains(textView.context.getString(R.string.url_domain_notifier, it, "some.place")))
}
}
@ -262,7 +262,7 @@ class LinkHelperTest {
builder.append(" ")
}
val markedUpContent = markupHiddenUrls(context, builder)
val markedUpContent = markupHiddenUrls(textView, builder)
for (mention in mentions) {
Assert.assertFalse(markedUpContent.contains("${getDomain(mention.url)})"))
}
@ -276,7 +276,7 @@ class LinkHelperTest {
builder.append(" ")
}
val markedUpContent = markupHiddenUrls(context, builder)
val markedUpContent = markupHiddenUrls(textView, builder)
for (mention in mentions) {
Assert.assertFalse(markedUpContent.contains("${getDomain(mention.url)})"))
}
@ -290,7 +290,7 @@ class LinkHelperTest {
builder.append(" ")
}
val markedUpContent = markupHiddenUrls(context, builder)
val markedUpContent = markupHiddenUrls(textView, builder)
for (tag in tags) {
Assert.assertFalse(markedUpContent.contains("${getDomain(tag.url)})"))
}
@ -304,7 +304,7 @@ class LinkHelperTest {
builder.append(" ")
}
val markedUpContent = markupHiddenUrls(context, builder)
val markedUpContent = markupHiddenUrls(textView, builder)
for (tag in tags) {
Assert.assertFalse(markedUpContent.contains("${getDomain(tag.url)})"))
}
@ -348,7 +348,6 @@ class LinkHelperTest {
arrayOf("https://pleroma.foo.bar/users/", false),
arrayOf("https://pleroma.foo.bar/users/meow/", false),
arrayOf("https://pleroma.foo.bar/users/@meow", false),
arrayOf("https://pleroma.foo.bar/user/2345", false),
arrayOf("https://pleroma.foo.bar/notices/123456", false),
arrayOf("https://pleroma.foo.bar/notice/@neverhappen/", false),
arrayOf("https://pleroma.foo.bar/object/abcdef-123-abcd-9876543", false),
@ -367,7 +366,9 @@ class LinkHelperTest {
arrayOf("https://pixelfed.social/connyduck", true),
arrayOf("https://gts.foo.bar/@goblin/statuses/01GH9XANCJ0TA8Y95VE9H3Y0Q2", true),
arrayOf("https://gts.foo.bar/@goblin", true),
arrayOf("https://foo.microblog.pub/o/5b64045effd24f48a27d7059f6cb38f5", true)
arrayOf("https://foo.microblog.pub/o/5b64045effd24f48a27d7059f6cb38f5", true),
arrayOf("https://bookwyrm.foo.bar/user/User", true),
arrayOf("https://bookwyrm.foo.bar/user/User/comment/123456", true)
)
}
}

View file

@ -1,13 +1,13 @@
package com.keylesspalace.tusky.util
import java.util.Locale
import kotlin.math.pow
import org.junit.AfterClass
import org.junit.Assert
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import java.util.Locale
import kotlin.math.pow
@RunWith(Parameterized::class)
class NumberUtilsTest(private val input: Long, private val want: String) {