Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Verified Commit 3941f5c5 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

test: add tests for PlayStore search paging

Add unit tests for Play Store paging source/repository, SearchViewModelV2 paging behavior, and icon URL resolution.

Fix SearchResultsContent compose tests to align with the updated API and locale-aware rating display.
parent 5634ecf5
Loading
Loading
Loading
Loading
Loading
+8 −2
Original line number Diff line number Diff line
@@ -49,6 +49,7 @@ import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.enums.Status
import foundation.e.apps.ui.compose.theme.AppTheme
import foundation.e.apps.ui.search.v2.SearchTabType
import java.util.Locale

@RunWith(AndroidJUnit4::class)
class SearchResultsContentTest {
@@ -103,6 +104,7 @@ class SearchResultsContentTest {
                        selectedTab = selectedTab,
                        fossItems = fossItems,
                        pwaItems = pwaItems,
                        searchVersion = 0,
                        getScrollPosition = { null },
                        onScrollPositionChange = { _, _, _ -> },
                        onTabSelect = { tab ->
@@ -129,6 +131,8 @@ class SearchResultsContentTest {
    fun applicationMapping_setsAuthorRatingAndPrimaryAction() {
        val notAvailable = composeRule.activity.getString(R.string.not_available)
        val openLabel = composeRule.activity.getString(R.string.open)
        val expectedRating = String.format(Locale.getDefault(), "%.1f", 4.4)
        val unexpectedRating = String.format(Locale.getDefault(), "%.1f", 4.9)

        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
@@ -164,10 +168,10 @@ class SearchResultsContentTest {
        )

        composeRule.onNodeWithText("com.example.rated").assertIsDisplayed()
        composeRule.onNodeWithText("4.4").assertIsDisplayed()
        composeRule.onNodeWithText(expectedRating).assertIsDisplayed()
        composeRule.onNodeWithText(openLabel).assertIsDisplayed()
        composeRule.onNodeWithText(notAvailable).assertIsDisplayed()
        composeRule.onAllNodesWithText("4.9").assertCountEquals(0)
        composeRule.onAllNodesWithText(unexpectedRating).assertCountEquals(0)
    }

    @Test
@@ -251,6 +255,7 @@ class SearchResultsContentTest {
        selectedTab: SearchTabType,
        fossPagingData: PagingData<Application>,
        pwaPagingData: PagingData<Application>? = null,
        searchVersion: Int = 0,
    ) {
        composeRule.setContent {
            val fossItems = remember { flowOf(fossPagingData) }.collectAsLazyPagingItems()
@@ -265,6 +270,7 @@ class SearchResultsContentTest {
                        selectedTab = selectedTab,
                        fossItems = fossItems,
                        pwaItems = pwaItems,
                        searchVersion = searchVersion,
                        getScrollPosition = { null },
                        onScrollPositionChange = { _, _, _ -> },
                        onTabSelect = {},
+44 −0
Original line number Diff line number Diff line
package foundation.e.apps.data.application.data

import com.google.common.truth.Truth.assertThat
import foundation.e.apps.data.cleanapk.CleanApkRetrofit
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.enums.Type
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -74,4 +76,46 @@ class ApplicationTest {
        assertThat(app.hasExodusPrivacyRating()).isTrue()
        assertThat(missing.hasExodusPrivacyRating()).isFalse()
    }

    @Test
    fun iconUrlReturnsNullWhenBlank() {
        val app = Application(icon_image_path = "   ")

        assertThat(app.iconUrl).isNull()
    }

    @Test
    fun iconUrlUsesCleanApkAssetsForOpenSourceRelativePath() {
        val app = Application(
            source = Source.OPEN_SOURCE,
            icon_image_path = "icons/app.png"
        )

        assertThat(app.iconUrl).isEqualTo(CleanApkRetrofit.ASSET_URL + "icons/app.png")
    }

    @Test
    fun iconUrlReturnsAbsolutePwaIconUrl() {
        val app = Application(
            source = Source.PWA,
            icon_image_path = "https://example.org/icon.png"
        )

        assertThat(app.iconUrl).isEqualTo("https://example.org/icon.png")
    }

    @Test
    fun iconUrlReturnsRawPathForPlayStoreAndSystemApps() {
        val playStoreApp = Application(
            source = Source.PLAY_STORE,
            icon_image_path = "content://playstore/icon.png"
        )
        val systemApp = Application(
            source = Source.SYSTEM_APP,
            icon_image_path = "system/icon.png"
        )

        assertThat(playStoreApp.iconUrl).isEqualTo("content://playstore/icon.png")
        assertThat(systemApp.iconUrl).isEqualTo("system/icon.png")
    }
}
+48 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * 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.
 *
 * This program 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 this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

package foundation.e.apps.data.search

import com.google.common.truth.Truth.assertThat
import io.mockk.mockk
import org.junit.Test

class PlayStorePagingRepositoryTest {

    private val playStoreWebSearch = mockk<PlayStoreWebSearch>(relaxed = true)
    private val repository = PlayStorePagingRepository(playStoreWebSearch)

    @Test
    fun `paging config uses provided size and defaults`() {
        val config = repository.buildPagingConfig(pageSize = 42)

        assertThat(config.pageSize).isEqualTo(42)
        assertThat(config.enablePlaceholders).isFalse()
        assertThat(config.prefetchDistance).isEqualTo(2)
    }

    @Test
    fun `paging source factory returns new instances`() {
        val factory = repository.buildPagingSourceFactory("query")

        val first = factory()
        val second = factory()

        assertThat(first).isNotSameInstanceAs(second)
    }
}
+252 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * 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.
 *
 * This program 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 this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

package foundation.e.apps.data.search

import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamBundle
import com.aurora.gplayapi.data.models.StreamCluster
import com.google.common.truth.Truth.assertThat
import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.io.IOException

class PlayStorePagingSourceTest {

    private val playStoreWebSearch = FakePlayStoreWebSearch()
    private lateinit var pagingSource: PlayStorePagingSource

    @Before
    fun setUp() {
        pagingSource = PlayStorePagingSource(
            query = "query",
            playStoreWebSearch = playStoreWebSearch
        )
    }

    @Test
    fun `load returns empty page when bundle has no clusters`() = runTest {
        playStoreWebSearch.searchResultsHandler = { streamBundle() }

        val result = pagingSource.load(refreshParams()) as PagingSource.LoadResult.Page

        assertThat(result.data).isEmpty()
        assertThat(result.prevKey).isNull()
        assertThat(result.nextKey).isNull()
    }

    @Test
    fun `load returns apps from bundle clusters`() = runTest {
        playStoreWebSearch.searchResultsHandler = {
            streamBundle(
                clusters = mapOf(
                    1 to streamCluster(apps = listOf(appWithPackage("one"))),
                    2 to streamCluster(apps = listOf(appWithPackage("two")))
                )
            )
        }

        val result = pagingSource.load(refreshParams()) as PagingSource.LoadResult.Page

        assertThat(result.data.map { it.packageName }).containsExactly("one", "two")
        assertThat(result.prevKey).isNull()
        assertThat(result.nextKey).isEqualTo(2)
    }

    @Test
    fun `load consumes stream urls before bundle next page`() = runTest {
        playStoreWebSearch.searchResultsHandler = {
            streamBundle(
                nextUrl = "bundle-next",
                clusters = mapOf(
                    1 to streamCluster(
                        nextUrl = "stream-next",
                        apps = listOf(appWithPackage("first"))
                    )
                )
            )
        }
        playStoreWebSearch.nextStreamClusterHandler = { _, _ ->
            streamCluster(apps = listOf(appWithPackage("stream-app")))
        }

        pagingSource.load(refreshParams())
        val result = pagingSource.load(nextPageParams()) as PagingSource.LoadResult.Page

        assertThat(result.data.map { it.packageName }).containsExactly("stream-app")
        assertThat(playStoreWebSearch.nextStreamClusterRequests).containsExactly("stream-next")
        assertThat(playStoreWebSearch.nextStreamBundleRequests).isEmpty()
    }

    @Test
    fun `load uses bundle next page when no stream urls remain`() = runTest {
        playStoreWebSearch.searchResultsHandler = {
            streamBundle(
                nextUrl = "bundle-next",
                clusters = mapOf(1 to streamCluster(apps = listOf(appWithPackage("first"))))
            )
        }
        playStoreWebSearch.nextStreamBundleHandler = { _, _ ->
            streamBundle(
                clusters = mapOf(1 to streamCluster(apps = listOf(appWithPackage("bundle-app"))))
            )
        }

        pagingSource.load(refreshParams())
        val result = pagingSource.load(nextPageParams()) as PagingSource.LoadResult.Page

        assertThat(result.data.map { it.packageName }).containsExactly("bundle-app")
        assertThat(playStoreWebSearch.nextStreamBundleRequests).containsExactly("bundle-next")
    }

    @Test
    fun `load returns empty when no next urls remain`() = runTest {
        playStoreWebSearch.searchResultsHandler = {
            streamBundle(
                clusters = mapOf(1 to streamCluster(apps = listOf(appWithPackage("first"))))
            )
        }

        pagingSource.load(refreshParams())
        val result = pagingSource.load(nextPageParams()) as PagingSource.LoadResult.Page

        assertThat(result.data).isEmpty()
        assertThat(result.nextKey).isNull()
    }

    @Test
    fun `load deduplicates apps by package name`() = runTest {
        playStoreWebSearch.searchResultsHandler = {
            streamBundle(
                clusters = mapOf(
                    1 to streamCluster(apps = listOf(appWithPackage("dup"))),
                    2 to streamCluster(apps = listOf(appWithPackage("dup")))
                )
            )
        }

        val result = pagingSource.load(refreshParams()) as PagingSource.LoadResult.Page

        assertThat(result.data.map { it.packageName }).containsExactly("dup")
    }

    @Test
    fun `load returns error on gplay http exception`() = runTest {
        playStoreWebSearch.searchResultsHandler = {
            throw GplayHttpRequestException(500, "failure")
        }

        val result = pagingSource.load(refreshParams())

        assertThat(result).isInstanceOf(PagingSource.LoadResult.Error::class.java)
    }

    @Test
    fun `load returns error on io exception`() = runTest {
        playStoreWebSearch.searchResultsHandler = { throw IOException("offline") }

        val result = pagingSource.load(refreshParams())

        assertThat(result).isInstanceOf(PagingSource.LoadResult.Error::class.java)
    }

    @Test
    fun `load returns error on illegal state exception`() = runTest {
        playStoreWebSearch.searchResultsHandler = { throw IllegalStateException("invalid") }

        val result = pagingSource.load(refreshParams())

        assertThat(result).isInstanceOf(PagingSource.LoadResult.Error::class.java)
    }

    @Test
    fun `getRefreshKey prefers prev key`() {
        val page = PagingSource.LoadResult.Page(
            data = listOf(appWithPackage("app")),
            prevKey = 1,
            nextKey = 3
        )

        val state = PagingState(
            pages = listOf(page),
            anchorPosition = 0,
            config = PagingConfig(pageSize = 2),
            leadingPlaceholderCount = 0
        )

        val refreshKey = pagingSource.getRefreshKey(state)

        assertThat(refreshKey).isEqualTo(2)
    }

    private fun refreshParams(): PagingSource.LoadParams<Int> =
        PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)

    private fun nextPageParams(): PagingSource.LoadParams<Int> =
        PagingSource.LoadParams.Refresh(key = 2, loadSize = 20, placeholdersEnabled = false)

    private fun appWithPackage(packageName: String): App {
        val app = mockk<App>(relaxed = true)
        every { app.packageName } returns packageName
        return app
    }

    private fun streamBundle(
        nextUrl: String = "",
        clusters: Map<Int, StreamCluster> = emptyMap(),
    ): StreamBundle = StreamBundle(
        streamNextPageUrl = nextUrl,
        streamClusters = clusters
    )

    private fun streamCluster(
        nextUrl: String = "",
        apps: List<App> = emptyList(),
    ): StreamCluster = StreamCluster(
        clusterNextPageUrl = nextUrl,
        clusterAppList = apps
    )

    private class FakePlayStoreWebSearch : PlayStoreWebSearch {
        var searchResultsHandler: (String) -> StreamBundle = { StreamBundle() }
        var nextStreamBundleHandler: (String, String) -> StreamBundle = { _, _ -> StreamBundle() }
        var nextStreamClusterHandler: (String, String) -> StreamCluster = { _, _ -> StreamCluster() }

        val nextStreamBundleRequests = mutableListOf<String>()
        val nextStreamClusterRequests = mutableListOf<String>()

        override suspend fun searchResults(query: String): StreamBundle = searchResultsHandler(query)

        override suspend fun nextStreamBundle(query: String, nextUrl: String): StreamBundle {
            nextStreamBundleRequests.add(nextUrl)
            return nextStreamBundleHandler(query, nextUrl)
        }

        override suspend fun nextStreamCluster(query: String, nextUrl: String): StreamCluster {
            nextStreamClusterRequests.add(nextUrl)
            return nextStreamClusterHandler(query, nextUrl)
        }
    }
}
+101 −0
Original line number Diff line number Diff line
@@ -20,7 +20,9 @@ package foundation.e.apps.ui.search.v2

import androidx.paging.AsyncPagingDataDiffer
import androidx.paging.PagingData
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import com.aurora.gplayapi.data.models.App
import foundation.e.apps.data.Stores
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.cleanapk.CleanApkRetrofit
@@ -325,6 +327,57 @@ class SearchViewModelV2Test {
        verify(exactly = 0) { searchPagingRepository.cleanApkSearch(any()) }
    }

    @Test
    fun `play store paging requests repository when tab visible`() = runTest {
        playStoreSelected = true
        openSourceSelected = false
        pwaSelected = false
        buildViewModel()
        every { playStorePagingRepository.playStoreSearch(any(), any()) } returns flowOf(PagingData.empty())

        viewModel.onSearchSubmitted("  android ")
        viewModel.playStorePagingFlow.first()

        verify { playStorePagingRepository.playStoreSearch("android", 20) }
    }

    @Test
    fun `play store paging emits empty when tab hidden`() = runTest {
        playStoreSelected = false
        openSourceSelected = true
        pwaSelected = false
        buildViewModel()
        every { playStorePagingRepository.playStoreSearch(any(), any()) } returns flowOf(
            PagingData.from(listOf(samplePlayStoreApp("ignored")))
        )

        viewModel.onSearchSubmitted("apps")
        val pagingData = viewModel.playStorePagingFlow.first()
        val items = collectPlayStoreApps(pagingData)

        assertTrue(items.isEmpty())
        verify(exactly = 0) { playStorePagingRepository.playStoreSearch(any(), any()) }
    }

    @Test
    fun `play store paging emits empty on blank query`() = runTest {
        playStoreSelected = true
        openSourceSelected = false
        pwaSelected = false
        buildViewModel()
        every { playStorePagingRepository.playStoreSearch(any(), any()) } returns flowOf(
            PagingData.from(listOf(samplePlayStoreApp("ignored")))
        )

        stores.enableStore(Source.OPEN_SOURCE)
        runStoreUpdates()
        val pagingData = viewModel.playStorePagingFlow.first()
        val items = collectPlayStoreApps(pagingData)

        assertTrue(items.isEmpty())
        verify(exactly = 0) { playStorePagingRepository.playStoreSearch(any(), any()) }
    }

    @Test
    fun `on suggestion selected delegates to search submission`() = runTest {
        playStoreSelected = true
@@ -365,6 +418,27 @@ class SearchViewModelV2Test {
        assertEquals(ScrollPosition(1, 8), viewModel.getScrollPosition(SearchTabType.PWA))
    }

    @Test
    fun `search submit increments search version`() = runTest {
        viewModel.onSearchSubmitted("first")
        val firstVersion = viewModel.uiState.value.searchVersion

        viewModel.onSearchSubmitted("second")

        assertEquals(firstVersion + 1, viewModel.uiState.value.searchVersion)
    }

    @Test
    fun `search submit clears scroll positions`() = runTest {
        viewModel.updateScrollPosition(SearchTabType.COMMON_APPS, 6, 4)
        viewModel.updateScrollPosition(SearchTabType.OPEN_SOURCE, 2, 9)

        viewModel.onSearchSubmitted("android")

        assertNull(viewModel.getScrollPosition(SearchTabType.COMMON_APPS))
        assertNull(viewModel.getScrollPosition(SearchTabType.OPEN_SOURCE))
    }

    private fun advanceDebounce() {
        mainCoroutineRule.testDispatcher.scheduler.advanceTimeBy(DEBOUNCE_MS)
        mainCoroutineRule.testDispatcher.scheduler.runCurrent()
@@ -387,6 +461,19 @@ class SearchViewModelV2Test {
        return differ.snapshot().items
    }

    private suspend fun collectPlayStoreApps(pagingData: PagingData<App>): List<App> {
        val differ = AsyncPagingDataDiffer(
            diffCallback = PlayStoreAppDiffUtil(),
            updateCallback = NoopListCallback(),
            mainDispatcher = mainCoroutineRule.testDispatcher,
            workerDispatcher = mainCoroutineRule.testDispatcher
        )

        differ.submitData(pagingData)
        mainCoroutineRule.testDispatcher.scheduler.advanceUntilIdle()
        return differ.snapshot().items
    }

    private fun visibleTabs(): List<SearchTabType> = buildList {
        if (playStoreSelected) add(SearchTabType.COMMON_APPS)
        if (openSourceSelected) add(SearchTabType.OPEN_SOURCE)
@@ -414,8 +501,22 @@ class SearchViewModelV2Test {
        override fun onChanged(position: Int, count: Int, payload: Any?) = Unit
    }

    private class PlayStoreAppDiffUtil : DiffUtil.ItemCallback<App>() {
        override fun areItemsTheSame(oldItem: App, newItem: App): Boolean =
            oldItem.packageName == newItem.packageName

        override fun areContentsTheSame(oldItem: App, newItem: App): Boolean =
            oldItem == newItem
    }

    private fun sampleApp(name: String) = Application(
        name = name,
        _id = name,
    )

    private fun samplePlayStoreApp(name: String): App {
        val app = mockk<App>(relaxed = true)
        every { app.packageName } returns name
        return app
    }
}