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

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

test: add tests for search suggestions in SearchViewModelV2

parent 4874776c
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -44,6 +44,6 @@ class PlayStoreSuggestionSource @Inject constructor(
                .distinctBy { it.lowercase(Locale.getDefault()) }
                .take(MAX_SUGGESTIONS)
                .toList()
        }.getOrElse { emptyList() }
        }.getOrDefault(emptyList())
    }
}
+102 −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.playstore.search

import com.google.common.truth.Truth.assertThat
import foundation.e.apps.data.application.search.SearchSuggestion
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.playstore.PlayStoreSearchHelper
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test

class PlayStoreSuggestionSourceTest {

    private val helper: PlayStoreSearchHelper = mockk()
    private lateinit var source: PlayStoreSuggestionSource

    @Before
    fun setUp() {
        source = PlayStoreSuggestionSource(helper)
    }

    @Test
    fun `blank query returns empty list without calling helper`() = runTest {
        val result = source.suggest("   ")

        assertThat(result).isEmpty()
        coVerify(exactly = 0) { helper.getSearchSuggestions(any()) }
    }

    @Test
    fun `non-blank query trims before calling helper and normalizes suggestions`() = runTest {
        coEvery { helper.getSearchSuggestions("signal") } returns suggestionsOf(
            "  Signal  ",
            "   ",
            "",
            "Firefox"
        )

        val result = source.suggest("  signal  ")

        assertThat(result).containsExactly("Signal", "Firefox").inOrder()
        coVerify(exactly = 1) { helper.getSearchSuggestions("signal") }
    }

    @Test
    fun `case-insensitive duplicates keep the first occurrence`() = runTest {
        coEvery { helper.getSearchSuggestions("vlc") } returns suggestionsOf(
            " VLC ",
            "vlc",
            "Vlc",
            "Firefox"
        )

        val result = source.suggest("vlc")

        assertThat(result).containsExactly("VLC", "Firefox").inOrder()
    }

    @Test
    fun `results are capped to ten suggestions`() = runTest {
        val titles = (1..12).map { index -> "App $index" }
        coEvery { helper.getSearchSuggestions("apps") } returns suggestionsOf(*titles.toTypedArray())

        val result = source.suggest("apps")

        assertThat(result).containsExactlyElementsIn(titles.take(10)).inOrder()
    }

    @Test
    fun `helper failure returns empty list`() = runTest {
        coEvery { helper.getSearchSuggestions("boom") } throws IllegalStateException("boom")

        val result = source.suggest("boom")

        assertThat(result).isEmpty()
        coVerify(exactly = 1) { helper.getSearchSuggestions("boom") }
    }

    private fun suggestionsOf(vararg titles: String): List<SearchSuggestion> {
        return titles.map { title -> SearchSuggestion(title, Source.PLAY_STORE) }
    }
}
+58 −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

/*
 * Copyright (C) 2025 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/>.
 *
 */

class FakeSuggestionSource(
    private val canned: List<String> = listOf(
        "Telegram",
        "Telegram FOSS",
        "Telegram X",
        "Fennec",
        "Firefox",
        "Signal",
        "NewPipe",
        "VLC",
    ),
) : SuggestionSource {

    override suspend fun suggest(query: String): List<String> {
        val lowered = query.trim().lowercase()
        if (lowered.isEmpty()) return emptyList()
        return canned.filter { item -> item.lowercase().contains(lowered) }
            .take(10)
    }
}
+130 −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.search.v2

import foundation.e.apps.data.search.FakeSuggestionSource
import foundation.e.apps.data.search.SuggestionSource
import foundation.e.apps.util.MainCoroutineRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test

private const val DEBOUNCE_MS = 200L

@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModelV2Test {

    @get:Rule
    val mainCoroutineRule = MainCoroutineRule()

    private lateinit var suggestionSource: SuggestionSource
    private lateinit var viewModel: SearchViewModelV2

    @Before
    fun setUp() {
        suggestionSource = FakeSuggestionSource()
        viewModel = SearchViewModelV2(suggestionSource)
    }

    @Test
    fun `non-blank query loads suggestions after debounce`() = runTest {
        viewModel.onQueryChanged("tel")

        advanceDebounce()

        val state = viewModel.uiState.value
        assertEquals(listOf("Telegram", "Telegram FOSS", "Telegram X"), state.suggestions.take(3))
        assertTrue(state.isSuggestionVisible)
        assertEquals("tel", state.query)
    }

    @Test
    fun `blank query clears suggestions and hides immediately`() = runTest {
        viewModel.onQueryChanged("tel")
        advanceDebounce()

        viewModel.onQueryChanged("   ")

        val state = viewModel.uiState.value
        assertTrue(state.suggestions.isEmpty())
        assertFalse(state.isSuggestionVisible)
        assertEquals("   ", state.query)
    }

    @Test
    fun `empty suggestions keep dropdown hidden`() = runTest {
        viewModel.onQueryChanged("zzz")

        advanceDebounce()

        val state = viewModel.uiState.value
        assertTrue(state.suggestions.isEmpty())
        assertFalse(state.isSuggestionVisible)
        assertEquals("zzz", state.query)
    }

    @Test
    fun `clear query resets state`() = runTest {
        viewModel.onQueryChanged("sig")
        advanceDebounce()

        viewModel.onQueryCleared()

        val state = viewModel.uiState.value
        assertEquals("", state.query)
        assertTrue(state.suggestions.isEmpty())
        assertFalse(state.isSuggestionVisible)
    }

    @Test
    fun `selecting suggestion commits value and hides dropdown`() = runTest {
        viewModel.onQueryChanged("tel")
        advanceDebounce()

        viewModel.onSuggestionSelected("Telegram X")

        val state = viewModel.uiState.value
        assertEquals("Telegram X", state.query)
        assertTrue(state.suggestions.isEmpty())
        assertFalse(state.isSuggestionVisible)
    }

    @Test
    fun `submitting search keeps query and clears suggestions`() = runTest {
        viewModel.onQueryChanged("sig")
        advanceDebounce()

        viewModel.onSearchSubmitted("Signal")

        val state = viewModel.uiState.value
        assertEquals("Signal", state.query)
        assertTrue(state.suggestions.isEmpty())
        assertFalse(state.isSuggestionVisible)
    }

    private fun advanceDebounce() {
        mainCoroutineRule.testDispatcher.scheduler.advanceTimeBy(DEBOUNCE_MS)
        mainCoroutineRule.testDispatcher.scheduler.runCurrent()
    }
}