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

Verified Commit 221c6dd2 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

test: cover search paging flows and load states

- add paging source tests for key calculation and error handling
- extend CleanApkSearchHelper and SearchViewModelV2 coverage for paging requests and scroll state
- update SearchResultsContent UI tests and add loader test tags for refresh/append assertions
parent b56d46ca
Loading
Loading
Loading
Loading
+191 −91
Original line number Diff line number Diff line
@@ -28,10 +28,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.paging.LoadState
import androidx.paging.LoadStates
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.flowOf
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import foundation.e.apps.R
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.application.data.Ratings
@@ -39,10 +49,6 @@ 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 org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class SearchResultsContentTest {
@@ -51,62 +57,54 @@ class SearchResultsContentTest {

    @Test
    fun emptyTabs_renderNothing() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
        val noAppsText = composeRule.activity.getString(R.string.no_apps_found)

        renderSearchResults(
            tabs = emptyList(),
            selectedTab = SearchTabType.COMMON_APPS,
                        resultsByTab = mapOf(
                            SearchTabType.COMMON_APPS to listOf(sampleApp("Hidden App"))
                        ),
                        onTabSelect = {},
            fossPagingData = PagingData.empty(),
        )
                }
            }
        }

        composeRule.onAllNodesWithText("Hidden App")
            .assertCountEquals(0)
        composeRule.onAllNodesWithText(noAppsText).assertCountEquals(0)
    }

    @Test
    fun selectedTabOutsideTabs_renderNothing() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
        val noAppsText = composeRule.activity.getString(R.string.no_apps_found)

        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.COMMON_APPS,
                        resultsByTab = mapOf(
                            SearchTabType.COMMON_APPS to listOf(sampleApp("Missing Tab App"))
                        ),
                        onTabSelect = {},
            fossPagingData = PagingData.from(listOf(sampleApp("Hidden App"))),
        )
                }
            }
        }

        composeRule.onAllNodesWithText("Missing Tab App")
            .assertCountEquals(0)
        composeRule.onAllNodesWithText(noAppsText).assertCountEquals(0)
    }

    @Test
    fun tabSelection_updatesDisplayedResults() {
        val selectedTabs = mutableListOf<SearchTabType>()
        val openSourceLabel = composeRule.activity.getString(R.string.search_tab_open_source)
        val pwaLabel = composeRule.activity.getString(R.string.search_tab_web_apps)

        composeRule.setContent {
            var selectedTab by remember { mutableStateOf(SearchTabType.COMMON_APPS) }
            var selectedTab by remember { mutableStateOf(SearchTabType.OPEN_SOURCE) }
            val fossItems = remember {
                flowOf(pagingData(listOf(sampleApp("Open App"))))
            }.collectAsLazyPagingItems()
            val pwaItems = remember {
                flowOf(pagingData(listOf(sampleApp("PWA App"))))
            }.collectAsLazyPagingItems()

            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
                        tabs = listOf(SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE),
                        tabs = listOf(SearchTabType.OPEN_SOURCE, SearchTabType.PWA),
                        selectedTab = selectedTab,
                        resultsByTab = mapOf(
                            SearchTabType.COMMON_APPS to listOf(sampleApp("Common App")),
                            SearchTabType.OPEN_SOURCE to listOf(sampleApp("Open App")),
                        ),
                        fossItems = fossItems,
                        pwaItems = pwaItems,
                        getScrollPosition = { null },
                        onScrollPositionChange = { _, _, _ -> },
                        onTabSelect = { tab ->
                            selectedTab = tab
                            selectedTabs.add(tab)
@@ -116,17 +114,14 @@ class SearchResultsContentTest {
            }
        }

        composeRule.onNodeWithText("Common App")
            .assertIsDisplayed()
        composeRule.onNodeWithText(openSourceLabel)
            .performClick()

        composeRule.onNodeWithText("Open App").assertIsDisplayed()
        composeRule.onNodeWithText(openSourceLabel).assertIsDisplayed()
        composeRule.onNodeWithText(pwaLabel).performClick()
        composeRule.waitForIdle()

        composeRule.onNodeWithText("Open App")
            .assertIsDisplayed()
        composeRule.onNodeWithText("PWA App").assertIsDisplayed()
        composeRule.runOnIdle {
            assertTrue(selectedTabs.contains(SearchTabType.OPEN_SOURCE))
            assertTrue(selectedTabs.contains(SearchTabType.PWA))
        }
    }

@@ -135,14 +130,11 @@ class SearchResultsContentTest {
        val notAvailable = composeRule.activity.getString(R.string.not_available)
        val openLabel = composeRule.activity.getString(R.string.open)

        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
                        tabs = listOf(SearchTabType.COMMON_APPS),
                        selectedTab = SearchTabType.COMMON_APPS,
                        resultsByTab = mapOf(
                            SearchTabType.COMMON_APPS to listOf(
        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.OPEN_SOURCE,
            fossPagingData = pagingData(
                listOf(
                    Application(
                        name = "Rated App",
                        author = "",
@@ -168,24 +160,132 @@ class SearchResultsContentTest {
                        status = Status.UPDATABLE,
                    ),
                )
                        ),
                        onTabSelect = {},
            )
        )

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

    @Test
    fun refreshLoading_showsShimmer() {
        val pagingData = PagingData.empty<Application>(
            sourceLoadStates = loadStates(refresh = LoadState.Loading)
        )

        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.OPEN_SOURCE,
            fossPagingData = pagingData,
        )

        composeRule.onNodeWithTag(SearchResultsContentTestTags.REFRESH_LOADER)
            .assertIsDisplayed()
        composeRule.onAllNodesWithText("Open App").assertCountEquals(0)
    }

    @Test
    fun refreshError_showsRetry() {
        val pagingData = PagingData.empty<Application>(
            sourceLoadStates = loadStates(refresh = LoadState.Error(RuntimeException("boom")))
        )

        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.OPEN_SOURCE,
            fossPagingData = pagingData,
        )

        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.search_error)
        ).assertIsDisplayed()
        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.retry)
        ).assertIsDisplayed()
    }

        composeRule.onNodeWithText("com.example.rated")
            .assertIsDisplayed()
        composeRule.onNodeWithText("4.4")
            .assertIsDisplayed()
        composeRule.onNodeWithText(openLabel)
            .assertIsDisplayed()
        composeRule.onNodeWithText(notAvailable)
    @Test
    fun emptyResults_showsPlaceholder() {
        val pagingData = PagingData.empty<Application>(
            sourceLoadStates = loadStates(
                refresh = LoadState.NotLoading(endOfPaginationReached = true)
            )
        )
        val noAppsText = composeRule.activity.getString(R.string.no_apps_found)

        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.OPEN_SOURCE,
            fossPagingData = pagingData,
        )

        composeRule.onNodeWithText(noAppsText).assertIsDisplayed()
    }

    @Test
    fun appendLoading_showsBottomSpinner() {
        val pagingData = PagingData.from(
            listOf(sampleApp("Open App")),
            sourceLoadStates = loadStates(
                refresh = LoadState.NotLoading(endOfPaginationReached = false),
                append = LoadState.Loading
            )
        )

        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.OPEN_SOURCE,
            fossPagingData = pagingData,
        )

        composeRule.onNodeWithText("Open App").assertIsDisplayed()
        composeRule.onNodeWithTag(SearchResultsContentTestTags.APPEND_LOADER)
            .assertIsDisplayed()
        composeRule.onAllNodesWithText("4.9")
            .assertCountEquals(0)
    }

    private fun renderSearchResults(
        tabs: List<SearchTabType>,
        selectedTab: SearchTabType,
        fossPagingData: PagingData<Application>,
        pwaPagingData: PagingData<Application>? = null,
    ) {
        composeRule.setContent {
            val fossItems = remember { flowOf(fossPagingData) }.collectAsLazyPagingItems()
            val pwaItems = pwaPagingData?.let {
                remember(it) { flowOf(it) }.collectAsLazyPagingItems()
            }

            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
                        tabs = tabs,
                        selectedTab = selectedTab,
                        fossItems = fossItems,
                        pwaItems = pwaItems,
                        getScrollPosition = { null },
                        onScrollPositionChange = { _, _, _ -> },
                        onTabSelect = {},
                    )
                }
            }
        }
    }

    private fun loadStates(
        refresh: LoadState,
        append: LoadState = LoadState.NotLoading(endOfPaginationReached = true),
        prepend: LoadState = LoadState.NotLoading(endOfPaginationReached = true),
    ) = LoadStates(refresh = refresh, prepend = prepend, append = append)

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

    private fun pagingData(apps: List<Application>) = PagingData.from(
        apps,
        sourceLoadStates = loadStates(
            refresh = LoadState.NotLoading(endOfPaginationReached = false)
        )
    )
}
+14 −2
Original line number Diff line number Diff line
@@ -39,6 +39,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
@@ -201,7 +202,9 @@ private fun PagingSearchResultList(
    Box(modifier = modifier) {
        when {
            isRefreshing -> {
                SearchShimmerList()
                SearchShimmerList(
                    modifier = Modifier.testTag(SearchResultsContentTestTags.REFRESH_LOADER)
                )
            }

            isError -> {
@@ -258,7 +261,11 @@ private fun PagingSearchResultList(
                                    .padding(vertical = 16.dp),
                                contentAlignment = Alignment.Center
                            ) {
                                CircularProgressIndicator(modifier = Modifier.size(24.dp))
                                CircularProgressIndicator(
                                    modifier = Modifier
                                        .size(24.dp)
                                        .testTag(SearchResultsContentTestTags.APPEND_LOADER)
                                )
                            }
                        }
                    }
@@ -359,3 +366,8 @@ private fun resolvePrimaryActionState(application: Application): PrimaryActionUi
        showMore = false,
    )
}

internal object SearchResultsContentTestTags {
    const val REFRESH_LOADER = "search_results_refresh_loader"
    const val APPEND_LOADER = "search_results_append_loader"
}
+73 −6
Original line number Diff line number Diff line
@@ -23,12 +23,6 @@
package foundation.e.apps.data.cleanapk

import com.google.common.truth.Truth.assertThat
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.cleanapk.data.search.Search
import foundation.e.apps.data.cleanapk.repositories.NUMBER_OF_ITEMS
import foundation.e.apps.data.cleanapk.repositories.NUMBER_OF_PAGES
import foundation.e.apps.data.enums.Source
import foundation.e.apps.utils.SystemInfoProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -36,11 +30,19 @@ import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlinx.coroutines.test.runTest
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Before
import org.junit.Test
import retrofit2.Response
import kotlin.test.assertFailsWith
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.cleanapk.data.search.Search
import foundation.e.apps.data.cleanapk.repositories.NUMBER_OF_ITEMS
import foundation.e.apps.data.cleanapk.repositories.NUMBER_OF_PAGES
import foundation.e.apps.data.enums.Source
import foundation.e.apps.utils.SystemInfoProvider

class CleanApkSearchHelperTest {

@@ -134,6 +136,71 @@ class CleanApkSearchHelperTest {
        }
    }

    @Test
    fun `getSearchResultPage throws when response unsuccessful`() = runTest {
        every { SystemInfoProvider.getSupportedArchitectureList() } returns emptyList()
        val responseBody = "error".toResponseBody("text/plain".toMediaType())
        coEvery {
            cleanApkRetrofit.searchApps(any(), any(), any(), any(), any(), any(), any())
        } returns Response.error(500, responseBody)

        val exception = assertFailsWith<IllegalStateException> {
            helper.getSearchResultPage(
                keyword = "fail",
                appSource = CleanApkRetrofit.APP_SOURCE_FOSS,
                appType = CleanApkRetrofit.APP_TYPE_NATIVE,
                page = 1,
                pageSize = 20,
            )
        }

        assertThat(exception.message).isEqualTo("CleanAPK search failed: HTTP 500")
    }

    @Test
    fun `getSearchResultPage throws when response success false`() = runTest {
        every { SystemInfoProvider.getSupportedArchitectureList() } returns emptyList()
        coEvery {
            cleanApkRetrofit.searchApps(any(), any(), any(), any(), any(), any(), any())
        } returns Response.success(Search(apps = emptyList(), success = false))

        val exception = assertFailsWith<IllegalStateException> {
            helper.getSearchResultPage(
                keyword = "fail",
                appSource = CleanApkRetrofit.APP_SOURCE_FOSS,
                appType = CleanApkRetrofit.APP_TYPE_NATIVE,
                page = 1,
                pageSize = 20,
            )
        }

        assertThat(exception.message).isEqualTo("CleanAPK search failed: success=false")
    }

    @Test
    fun `getSearchResultPage maps source on apps`() = runTest {
        val architectures = listOf("arm64-v8a")
        every { SystemInfoProvider.getSupportedArchitectureList() } returns architectures
        val pwaApp = Application(_id = "pwa", is_pwa = true)
        val nativeApp = Application(_id = "native", is_pwa = false)
        coEvery {
            cleanApkRetrofit.searchApps(any(), any(), any(), any(), any(), any(), any())
        } returns Response.success(
            Search(apps = listOf(pwaApp, nativeApp), pages = 2, success = true)
        )

        val result = helper.getSearchResultPage(
            keyword = "apps",
            appSource = CleanApkRetrofit.APP_SOURCE_FOSS,
            appType = CleanApkRetrofit.APP_TYPE_NATIVE,
            page = 1,
            pageSize = 20,
        )

        assertThat(result.apps.first { it._id == "pwa" }.source).isEqualTo(Source.PWA)
        assertThat(result.apps.first { it._id == "native" }.source).isEqualTo(Source.OPEN_SOURCE)
    }

    private fun invokeMapSource(app: Application): Source {
        val method =
            CleanApkSearchHelper::class.java.getDeclaredMethod("mapSource", Application::class.java)
+207 −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.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Before
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.cleanapk.CleanApkSearchHelper
import foundation.e.apps.data.cleanapk.data.search.Search
import java.io.IOException

class CleanApkSearchPagingSourceTest {

    private val cleanApkSearchHelper = mockk<CleanApkSearchHelper>()
    private val params = CleanApkSearchParams(
        keyword = "query",
        appSource = "source",
        appType = "type",
        pageSize = 2
    )
    private lateinit var pagingSource: CleanApkSearchPagingSource

    @Before
    fun setUp() {
        pagingSource = CleanApkSearchPagingSource(cleanApkSearchHelper, params)
    }

    @Test
    fun `load returns keys for first page`() = runTest {
        coEvery {
            cleanApkSearchHelper.getSearchResultPage("query", "source", "type", 1, 2)
        } returns Search(
            apps = listOf(Application(name = "First")),
            pages = 3,
            success = true
        )

        val result = pagingSource.load(
            PagingSource.LoadParams.Refresh(key = null, loadSize = 2, placeholdersEnabled = false)
        ) as PagingSource.LoadResult.Page

        assertThat(result.data).hasSize(1)
        assertThat(result.prevKey).isNull()
        assertThat(result.nextKey).isEqualTo(2)
    }

    @Test
    fun `load returns keys for middle page`() = runTest {
        coEvery {
            cleanApkSearchHelper.getSearchResultPage("query", "source", "type", 2, 2)
        } returns Search(
            apps = listOf(Application(name = "Middle")),
            pages = 3,
            success = true
        )

        val result = pagingSource.load(
            PagingSource.LoadParams.Refresh(key = 2, loadSize = 2, placeholdersEnabled = false)
        ) as PagingSource.LoadResult.Page

        assertThat(result.prevKey).isEqualTo(1)
        assertThat(result.nextKey).isEqualTo(3)
    }

    @Test
    fun `load returns no next key for last page`() = runTest {
        coEvery {
            cleanApkSearchHelper.getSearchResultPage("query", "source", "type", 3, 2)
        } returns Search(
            apps = listOf(Application(name = "Last")),
            pages = 3,
            success = true
        )

        val result = pagingSource.load(
            PagingSource.LoadParams.Refresh(key = 3, loadSize = 2, placeholdersEnabled = false)
        ) as PagingSource.LoadResult.Page

        assertThat(result.prevKey).isEqualTo(2)
        assertThat(result.nextKey).isNull()
    }

    @Test
    fun `getRefreshKey prefers prev key`() {
        val page = PagingSource.LoadResult.Page(
            data = listOf(Application(name = "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)
    }

    @Test
    fun `getRefreshKey falls back to next key`() {
        val page = PagingSource.LoadResult.Page(
            data = listOf(Application(name = "App")),
            prevKey = null,
            nextKey = 2
        )

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

        val refreshKey = pagingSource.getRefreshKey(state)

        assertThat(refreshKey).isEqualTo(1)
    }

    @Test
    fun `getRefreshKey returns null when no keys`() {
        val page = PagingSource.LoadResult.Page<Int, Application>(
            data = listOf(Application(name = "App")),
            prevKey = null,
            nextKey = null
        )

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

        val refreshKey = pagingSource.getRefreshKey(state)

        assertThat(refreshKey).isNull()
    }

    @Test
    fun `load returns error on IOException`() = runTest {
        coEvery { cleanApkSearchHelper.getSearchResultPage(any(), any(), any(), any(), any()) } throws
            IOException("offline")

        val result = pagingSource.load(
            PagingSource.LoadParams.Refresh(key = null, loadSize = 2, placeholdersEnabled = false)
        )

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

    @Test
    fun `load returns error on HttpException`() = runTest {
        val responseBody = "error".toResponseBody("text/plain".toMediaType())
        val httpException = HttpException(Response.error<String>(500, responseBody))
        coEvery { cleanApkSearchHelper.getSearchResultPage(any(), any(), any(), any(), any()) } throws
            httpException

        val result = pagingSource.load(
            PagingSource.LoadParams.Refresh(key = null, loadSize = 2, placeholdersEnabled = false)
        )

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

    @Test
    fun `load returns error on IllegalStateException`() = runTest {
        coEvery { cleanApkSearchHelper.getSearchResultPage(any(), any(), any(), any(), any()) } throws
            IllegalStateException("invalid")

        val result = pagingSource.load(
            PagingSource.LoadParams.Refresh(key = null, loadSize = 2, placeholdersEnabled = false)
        )

        assertThat(result).isInstanceOf(PagingSource.LoadResult.Error::class.java)
    }
}
+84 −0

File changed.

Preview size limit exceeded, changes collapsed.