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

Commit 8b097ba1 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury Committed by Fahim M. Choudhury
Browse files

test: add tests for search results list item and search results content

parent 62ce5bb3
Loading
Loading
Loading
Loading
+298 −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.ui.compose.components

import androidx.activity.ComponentActivity
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import foundation.e.apps.R
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.ui.compose.theme.AppTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class SearchResultListItemTest {
    @get:Rule
    val composeRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun placeholderState_showsLoadingOnly() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultListItem(
                        application = sampleApp("Placeholder App"),
                        uiState = placeholderState(),
                        onItemClick = {},
                        onPrimaryActionClick = {},
                        onShowMoreClick = {},
                        onPrivacyClick = {},
                    )
                }
            }
        }

        composeRule.onNodeWithTag(SearchResultListItemTestTags.PLACEHOLDER)
            .assertIsDisplayed()
        composeRule.onAllNodesWithText("Placeholder App")
            .assertCountEquals(0)
    }

    @Test
    fun metadataAndClicks_areWiredCorrectly() {
        var itemClicks = 0
        var primaryClicks = 0
        var privacyClicks = 0

        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultListItem(
                        application = sampleApp("Signal"),
                        uiState = defaultState(
                            author = "Signal LLC",
                            ratingText = "4.5",
                            showRating = true,
                            sourceTag = "Play Store",
                            showSourceTag = true,
                            privacyScore = "06/10",
                            showPrivacyScore = true,
                            isPrivacyLoading = false,
                            primaryAction = PrimaryActionUiState(
                                label = "Install",
                                enabled = true,
                                isInProgress = false,
                                isFilledStyle = false,
                            ),
                        ),
                        onItemClick = { itemClicks += 1 },
                        onPrimaryActionClick = { primaryClicks += 1 },
                        onShowMoreClick = {},
                        onPrivacyClick = { privacyClicks += 1 },
                    )
                }
            }
        }

        composeRule.onNodeWithText("Signal").assertIsDisplayed()
        composeRule.onNodeWithText("Signal LLC").assertIsDisplayed()
        composeRule.onNodeWithText("4.5").assertIsDisplayed()
        composeRule.onNodeWithText("Play Store").assertIsDisplayed()
        composeRule.onNodeWithText("Install").assertIsDisplayed()
        composeRule.onNodeWithText("06/10").assertIsDisplayed()
        composeRule.onAllNodesWithTag(SearchResultListItemTestTags.SHOW_MORE)
            .assertCountEquals(0)

        composeRule.onNodeWithTag(SearchResultListItemTestTags.ROOT)
            .performClick()
        composeRule.onNodeWithTag(SearchResultListItemTestTags.PRIMARY_BUTTON)
            .performClick()
        composeRule.onNodeWithTag(SearchResultListItemTestTags.PRIVACY_BADGE)
            .performClick()

        composeRule.runOnIdle {
            assertEquals(1, itemClicks)
            assertEquals(1, primaryClicks)
            assertEquals(1, privacyClicks)
        }
    }

    @Test
    fun hidesRatingAndSourceTag_whenDisabled() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultListItem(
                        application = sampleApp("No Rating"),
                        uiState = defaultState(
                            author = "Anonymous",
                            ratingText = "4.9",
                            showRating = false,
                            sourceTag = "Play Store",
                            showSourceTag = false,
                            privacyScore = "",
                            showPrivacyScore = false,
                            isPrivacyLoading = false,
                            primaryAction = PrimaryActionUiState(
                                label = "Install",
                                enabled = true,
                                isInProgress = false,
                                isFilledStyle = true,
                            ),
                        ),
                        onItemClick = {},
                        onPrimaryActionClick = {},
                        onShowMoreClick = {},
                        onPrivacyClick = {},
                    )
                }
            }
        }

        composeRule.onAllNodesWithText("4.9").assertCountEquals(0)
        composeRule.onAllNodesWithText("Play Store").assertCountEquals(0)
    }

    @Test
    fun showMore_replacesPrimaryButton_andFiresCallback() {
        var showMoreClicks = 0
        val showMoreLabel = composeRule.activity.getString(R.string.show_more)

        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultListItem(
                        application = sampleApp("Show More App"),
                        uiState = defaultState(
                            author = "Author",
                            ratingText = "",
                            showRating = false,
                            sourceTag = "",
                            showSourceTag = false,
                            privacyScore = "",
                            showPrivacyScore = false,
                            isPrivacyLoading = false,
                            primaryAction = PrimaryActionUiState(
                                label = "Install",
                                enabled = true,
                                isInProgress = false,
                                isFilledStyle = true,
                                showMore = true,
                            ),
                        ),
                        onItemClick = {},
                        onPrimaryActionClick = {},
                        onShowMoreClick = { showMoreClicks += 1 },
                        onPrivacyClick = {},
                    )
                }
            }
        }

        composeRule.onNodeWithText(showMoreLabel)
            .assertIsDisplayed()
        composeRule.onNodeWithTag(SearchResultListItemTestTags.SHOW_MORE)
            .performClick()
        composeRule.onAllNodesWithTag(SearchResultListItemTestTags.PRIMARY_BUTTON)
            .assertCountEquals(0)

        composeRule.runOnIdle {
            assertEquals(1, showMoreClicks)
        }
    }

    @Test
    fun inProgressPrimaryAction_andPrivacyLoading_showSpinners() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultListItem(
                        application = sampleApp("Progress App"),
                        uiState = defaultState(
                            author = "Author",
                            ratingText = "",
                            showRating = false,
                            sourceTag = "",
                            showSourceTag = false,
                            privacyScore = "07/10",
                            showPrivacyScore = true,
                            isPrivacyLoading = true,
                            primaryAction = PrimaryActionUiState(
                                label = "Download",
                                enabled = true,
                                isInProgress = true,
                                isFilledStyle = true,
                            ),
                        ),
                        onItemClick = {},
                        onPrimaryActionClick = {},
                        onShowMoreClick = {},
                        onPrivacyClick = {},
                    )
                }
            }
        }

        composeRule.onNodeWithTag(SearchResultListItemTestTags.PRIMARY_PROGRESS)
            .assertIsDisplayed()
        composeRule.onAllNodesWithText("Download").assertCountEquals(0)
        composeRule.onNodeWithTag(SearchResultListItemTestTags.PRIVACY_PROGRESS)
            .assertIsDisplayed()
        composeRule.onAllNodesWithText("07/10").assertCountEquals(0)
    }

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

    private fun placeholderState(): SearchResultListItemState = SearchResultListItemState(
        author = "",
        ratingText = "",
        showRating = false,
        sourceTag = "",
        showSourceTag = false,
        privacyScore = "",
        showPrivacyScore = false,
        isPrivacyLoading = false,
        primaryAction = PrimaryActionUiState(
            label = "",
            enabled = false,
            isInProgress = false,
            isFilledStyle = true,
        ),
        iconUrl = null,
        placeholderResId = null,
        isPlaceholder = true,
    )

    private fun defaultState(
        author: String,
        ratingText: String,
        showRating: Boolean,
        sourceTag: String,
        showSourceTag: Boolean,
        privacyScore: String,
        showPrivacyScore: Boolean,
        isPrivacyLoading: Boolean,
        primaryAction: PrimaryActionUiState,
    ): SearchResultListItemState = SearchResultListItemState(
        author = author,
        ratingText = ratingText,
        showRating = showRating,
        sourceTag = sourceTag,
        showSourceTag = showSourceTag,
        privacyScore = privacyScore,
        showPrivacyScore = showPrivacyScore,
        isPrivacyLoading = isPrivacyLoading,
        primaryAction = primaryAction,
        iconUrl = null,
        placeholderResId = null,
        isPlaceholder = false,
    )
}
+191 −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.ui.compose.components

import androidx.activity.ComponentActivity
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import foundation.e.apps.R
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.application.data.Ratings
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 {
    @get:Rule
    val composeRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun emptyTabs_renderNothing() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
                        tabs = emptyList(),
                        selectedTab = SearchTabType.COMMON_APPS,
                        resultsByTab = mapOf(
                            SearchTabType.COMMON_APPS to listOf(sampleApp("Hidden App"))
                        ),
                        onTabSelect = {},
                    )
                }
            }
        }

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

    @Test
    fun selectedTabOutsideTabs_renderNothing() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
                        tabs = listOf(SearchTabType.OPEN_SOURCE),
                        selectedTab = SearchTabType.COMMON_APPS,
                        resultsByTab = mapOf(
                            SearchTabType.COMMON_APPS to listOf(sampleApp("Missing Tab App"))
                        ),
                        onTabSelect = {},
                    )
                }
            }
        }

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

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

        composeRule.setContent {
            var selectedTab by remember { mutableStateOf(SearchTabType.COMMON_APPS) }
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
                        tabs = listOf(SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE),
                        selectedTab = selectedTab,
                        resultsByTab = mapOf(
                            SearchTabType.COMMON_APPS to listOf(sampleApp("Common App")),
                            SearchTabType.OPEN_SOURCE to listOf(sampleApp("Open App")),
                        ),
                        onTabSelect = { tab ->
                            selectedTab = tab
                            selectedTabs.add(tab)
                        },
                    )
                }
            }
        }

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

        composeRule.waitForIdle()

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

    @Test
    fun applicationMapping_setsAuthorRatingAndPrimaryAction() {
        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(
                                Application(
                                    name = "Rated App",
                                    author = "",
                                    package_name = "com.example.rated",
                                    source = Source.PLAY_STORE,
                                    ratings = Ratings(usageQualityScore = 4.4),
                                    status = Status.INSTALLED,
                                ),
                                Application(
                                    name = "Unrated App",
                                    author = "Team",
                                    package_name = "com.example.unrated",
                                    source = Source.PLAY_STORE,
                                    ratings = Ratings(usageQualityScore = -1.0),
                                    status = Status.UPDATABLE,
                                ),
                                Application(
                                    name = "Foss App",
                                    author = "Foss Team",
                                    package_name = "org.example.foss",
                                    source = Source.OPEN_SOURCE,
                                    ratings = Ratings(usageQualityScore = 4.9),
                                    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)
    }

    private fun sampleApp(name: String) = Application(name = name)
}
+29 −6
Original line number Diff line number Diff line
@@ -44,6 +44,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -77,6 +78,7 @@ fun SearchResultListItem(
    Row(
        modifier = modifier
            .fillMaxWidth()
            .testTag(SearchResultListItemTestTags.ROOT)
            .clickable(
                interactionSource = interactionSource,
                indication = null,
@@ -218,7 +220,9 @@ private fun PrivacyBadge(

    Row(
        verticalAlignment = Alignment.CenterVertically,
        modifier = Modifier.clickable(onClick = onClick),
        modifier = Modifier
            .testTag(SearchResultListItemTestTags.PRIVACY_BADGE)
            .clickable(onClick = onClick),
    ) {
        Image(
            painter = painterResource(id = R.drawable.ic_lock),
@@ -228,7 +232,9 @@ private fun PrivacyBadge(
        Spacer(modifier = Modifier.width(4.dp))
        if (isLoading) {
            CircularProgressIndicator(
                modifier = Modifier.size(16.dp),
                modifier = Modifier
                    .size(16.dp)
                    .testTag(SearchResultListItemTestTags.PRIVACY_PROGRESS),
                strokeWidth = 2.dp,
            )
        } else {
@@ -256,7 +262,9 @@ private fun PrimaryActionArea(
            text = stringResource(id = R.string.show_more),
            style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold),
            color = MaterialTheme.colorScheme.primary,
            modifier = Modifier.clickable(onClick = onShowMoreClick),
            modifier = Modifier
                .testTag(SearchResultListItemTestTags.SHOW_MORE)
                .clickable(onClick = onShowMoreClick),
        )
        return
    } else {
@@ -268,7 +276,9 @@ private fun PrimaryActionArea(
            val indicatorColor =
                if (uiState.isFilledStyle) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onPrimary
            CircularProgressIndicator(
                modifier = Modifier.size(16.dp),
                modifier = Modifier
                    .size(16.dp)
                    .testTag(SearchResultListItemTestTags.PRIMARY_PROGRESS),
                strokeWidth = 2.dp,
                color = indicatorColor,
            )
@@ -298,7 +308,9 @@ private fun PrimaryActionArea(
        Button(
            onClick = onPrimaryClick,
            enabled = uiState.enabled,
            modifier = Modifier.height(40.dp),
            modifier = Modifier
                .height(40.dp)
                .testTag(SearchResultListItemTestTags.PRIMARY_BUTTON),
            shape = RoundedCornerShape(4.dp),
            colors = ButtonDefaults.buttonColors(
                containerColor = containerColor,
@@ -328,7 +340,8 @@ private fun PlaceholderRow(modifier: Modifier = Modifier) {
    Box(
        modifier = modifier
            .fillMaxWidth()
            .padding(vertical = 16.dp),
            .padding(vertical = 16.dp)
            .testTag(SearchResultListItemTestTags.PLACEHOLDER),
        contentAlignment = Alignment.Center,
    ) {
        CircularProgressIndicator()
@@ -358,6 +371,16 @@ data class PrimaryActionUiState(
    val showMore: Boolean = false,
)

internal object SearchResultListItemTestTags {
    const val ROOT = "search_result_item_root"
    const val PLACEHOLDER = "search_result_item_placeholder"
    const val SHOW_MORE = "search_result_item_show_more"
    const val PRIMARY_BUTTON = "search_result_item_primary_button"
    const val PRIMARY_PROGRESS = "search_result_item_primary_progress"
    const val PRIVACY_BADGE = "search_result_item_privacy_badge"
    const val PRIVACY_PROGRESS = "search_result_item_privacy_progress"
}

// --- Previews ---

@Preview(showBackground = true)
+12 −0
Original line number Diff line number Diff line
@@ -111,6 +111,18 @@ class SearchViewModelV2Test {
        assertEquals("tel", state.query)
    }

    @Test
    fun `empty suggestions keep dropdown hidden`() = runTest {
        playStoreSelected = true

        viewModel.onQueryChanged("zzzz")
        advanceDebounce()

        val state = viewModel.uiState.value
        assertTrue(state.suggestions.isEmpty())
        assertFalse(state.isSuggestionVisible)
    }

    @Test
    fun `blank query before submit clears tabs and results`() = runTest {
        viewModel.onQueryChanged("   ")