Loading app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +2 −2 Original line number Diff line number Diff line Loading @@ -152,7 +152,7 @@ class SearchViewModelV2 @Inject constructor( onSearchSubmitted(suggestion) } fun onClearQuery() { fun onQueryCleared() { suggestionJob?.cancel() _uiState.update { current -> if (current.hasSubmittedSearch && current.availableTabs.isNotEmpty()) { Loading @@ -179,7 +179,7 @@ class SearchViewModelV2 @Inject constructor( fun onSearchSubmitted(submitted: String) { val trimmedQuery = submitted.trim() if (trimmedQuery.isEmpty()) { onClearQuery() onQueryCleared() return } Loading app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +235 −30 Original line number Diff line number Diff line Loading @@ -18,13 +18,24 @@ package foundation.e.apps.ui.search.v2 import android.content.SharedPreferences import foundation.e.apps.data.Constants.PREFERENCE_SHOW_FOSS import foundation.e.apps.data.Constants.PREFERENCE_SHOW_GPLAY import foundation.e.apps.data.Stores import foundation.e.apps.data.cleanapk.repositories.CleanApkAppsRepository import foundation.e.apps.data.cleanapk.repositories.CleanApkPwaRepository import foundation.e.apps.data.playstore.PlayStoreRepository import foundation.e.apps.data.preference.AppLoungePreference import foundation.e.apps.data.search.FakeSuggestionSource import foundation.e.apps.data.search.SuggestionSource import foundation.e.apps.util.MainCoroutineRule import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule Loading @@ -38,93 +49,287 @@ class SearchViewModelV2Test { @get:Rule val mainCoroutineRule = MainCoroutineRule() private lateinit var suggestionSource: SuggestionSource private lateinit var suggestionSource: FakeSuggestionSource private lateinit var preference: AppLoungePreference private lateinit var stores: Stores private var playStoreSelected = true private var openSourceSelected = true private var pwaSelected = false private var preferenceListener: SharedPreferences.OnSharedPreferenceChangeListener? = null private lateinit var viewModel: SearchViewModelV2 @Before fun setUp() { suggestionSource = FakeSuggestionSource() viewModel = SearchViewModelV2(suggestionSource) preference = mockk(relaxed = true) stores = buildStores() every { preference.isPlayStoreSelected() } answers { playStoreSelected } every { preference.isOpenSourceSelected() } answers { openSourceSelected } every { preference.isPWASelected() } answers { pwaSelected } every { preference.registerStorePreferenceListener(any()) } answers { preferenceListener = arg(0) Unit } every { preference.unregisterStorePreferenceListener(any()) } answers { if (preferenceListener == arg<SharedPreferences.OnSharedPreferenceChangeListener>(0)) { preferenceListener = null } Unit } buildViewModel() } private fun buildStores(): Stores { val playStoreRepository = mockk<PlayStoreRepository>(relaxed = true) val cleanApkAppsRepository = mockk<CleanApkAppsRepository>(relaxed = true) val cleanApkPwaRepository = mockk<CleanApkPwaRepository>(relaxed = true) return Stores( playStoreRepository, cleanApkAppsRepository, cleanApkPwaRepository, preference ) } @Test fun `non-blank query loads suggestions after debounce`() = runTest { viewModel.onQueryChanged("tel") fun `play store disabled hides suggestions when typing`() = runTest { playStoreSelected = false viewModel.onQueryChanged("apps") advanceDebounce() val state = viewModel.uiState.value assertEquals(listOf("Telegram", "Telegram FOSS", "Telegram X"), state.suggestions.take(3)) assertTrue(state.isSuggestionVisible) assertEquals("tel", state.query) assertTrue(state.suggestions.isEmpty()) assertFalse(state.isSuggestionVisible) assertEquals("apps", state.query) } @Test fun `blank query clears suggestions and hides immediately`() = runTest { fun `matching query shows suggestions when play store enabled`() = runTest { playStoreSelected = true viewModel.onQueryChanged("tel") advanceDebounce() val state = viewModel.uiState.value assertFalse(state.suggestions.isEmpty()) assertTrue(state.isSuggestionVisible) assertEquals("tel", state.query) } @Test fun `blank query before submit clears tabs and results`() = runTest { viewModel.onQueryChanged(" ") val state = viewModel.uiState.value assertTrue(state.suggestions.isEmpty()) assertTrue(state.resultsByTab.isEmpty()) assertEquals(visibleTabs(), state.availableTabs) assertEquals(visibleTabs().firstOrNull(), state.selectedTab) assertFalse(state.hasSubmittedSearch) } @Test fun `blank query after submit hides suggestions but keeps results`() = runTest { viewModel.onSearchSubmitted("query") val resultsBefore = viewModel.uiState.value.resultsByTab val tabsBefore = viewModel.uiState.value.availableTabs viewModel.onQueryChanged(" ") val state = viewModel.uiState.value assertEquals(resultsBefore, state.resultsByTab) assertEquals(tabsBefore, state.availableTabs) assertTrue(state.hasSubmittedSearch) assertFalse(state.isSuggestionVisible) assertEquals("", state.query) } @Test fun `empty suggestions keep dropdown hidden`() = runTest { viewModel.onQueryChanged("zzz") fun `clear query after submit retains tabs and results`() = runTest { viewModel.onSearchSubmitted("query") val resultsBefore = viewModel.uiState.value.resultsByTab val tabsBefore = viewModel.uiState.value.availableTabs advanceDebounce() viewModel.onQueryCleared() val state = viewModel.uiState.value assertEquals(resultsBefore, state.resultsByTab) assertEquals(tabsBefore, state.availableTabs) assertTrue(state.hasSubmittedSearch) assertEquals("", state.query) assertFalse(state.isSuggestionVisible) } @Test fun `search submit trims query and builds per tab results`() = runTest { playStoreSelected = true openSourceSelected = true pwaSelected = true buildViewModel() viewModel.onSearchSubmitted(" spaced query ") val state = viewModel.uiState.value assertEquals("spaced query", state.query) assertEquals(visibleTabs(), state.availableTabs) assertTrue(state.resultsByTab.keys.containsAll(visibleTabs())) assertTrue(state.resultsByTab[SearchTabType.WEB_APPS]!!.all { it.contains("spaced query") }) assertTrue(state.resultsByTab.values.all { it.size == 6 }) assertTrue(state.hasSubmittedSearch) assertTrue(state.suggestions.isEmpty()) assertFalse(state.isSuggestionVisible) assertEquals("zzz", state.query) } @Test fun `clear query resets state`() = runTest { viewModel.onQueryChanged("sig") advanceDebounce() fun `search submit with no visible tabs yields no results`() = runTest { playStoreSelected = false openSourceSelected = false pwaSelected = false buildViewModel() viewModel.onSearchSubmitted("anything") viewModel.onQueryCleared() val state = viewModel.uiState.value assertTrue(state.availableTabs.isEmpty()) assertTrue(state.resultsByTab.isEmpty()) assertNull(state.selectedTab) assertFalse(state.hasSubmittedSearch) } @Test fun `search submit with blank query clears state`() = runTest { playStoreSelected = true buildViewModel() viewModel.onSearchSubmitted(" ") val state = viewModel.uiState.value assertTrue(state.resultsByTab.isEmpty()) assertFalse(state.hasSubmittedSearch) assertEquals(visibleTabs(), state.availableTabs) assertEquals(visibleTabs().firstOrNull(), state.selectedTab) assertEquals("", state.query) assertTrue(state.suggestions.isEmpty()) assertFalse(state.isSuggestionVisible) } @Test fun `selecting suggestion commits value and hides dropdown`() = runTest { fun `store change after submit rebuilds tabs and results`() = runTest { playStoreSelected = true openSourceSelected = false pwaSelected = false buildViewModel() viewModel.onSearchSubmitted("apps") playStoreSelected = false openSourceSelected = true notifyPreferenceChange(PREFERENCE_SHOW_FOSS) val state = viewModel.uiState.value assertEquals(listOf(SearchTabType.OPEN_SOURCE), state.availableTabs) assertEquals(SearchTabType.OPEN_SOURCE, state.selectedTab) assertTrue(state.resultsByTab.keys == setOf(SearchTabType.OPEN_SOURCE)) assertTrue(state.resultsByTab[SearchTabType.OPEN_SOURCE]!!.all { it.contains("apps") }) assertTrue(state.hasSubmittedSearch) } @Test fun `store change hides suggestions when play store turns off`() = runTest { playStoreSelected = true buildViewModel() viewModel.onQueryChanged("tel") advanceDebounce() assertTrue(viewModel.uiState.value.isSuggestionVisible) viewModel.onSuggestionSelected("Telegram X") playStoreSelected = false notifyPreferenceChange(PREFERENCE_SHOW_GPLAY) 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() fun `store change removing all tabs clears submitted state`() = runTest { playStoreSelected = true openSourceSelected = false pwaSelected = false buildViewModel() viewModel.onSearchSubmitted("apps") viewModel.onSearchSubmitted("Signal") playStoreSelected = false openSourceSelected = false pwaSelected = false notifyPreferenceChange(PREFERENCE_SHOW_GPLAY) val state = viewModel.uiState.value assertTrue(state.availableTabs.isEmpty()) assertTrue(state.resultsByTab.isEmpty()) assertNull(state.selectedTab) assertFalse(state.hasSubmittedSearch) } @Test fun `tab selection ignores unavailable tabs`() = runTest { playStoreSelected = true openSourceSelected = true pwaSelected = false buildViewModel() viewModel.onTabSelected(SearchTabType.WEB_APPS) assertEquals(SearchTabType.COMMON_APPS, viewModel.uiState.value.selectedTab) viewModel.onTabSelected(SearchTabType.OPEN_SOURCE) assertEquals(SearchTabType.OPEN_SOURCE, viewModel.uiState.value.selectedTab) } @Test fun `on suggestion selected delegates to search submission`() = runTest { playStoreSelected = true buildViewModel() viewModel.onSuggestionSelected("Signal ") val state = viewModel.uiState.value assertEquals("Signal", state.query) assertTrue(state.suggestions.isEmpty()) assertFalse(state.isSuggestionVisible) assertTrue(state.hasSubmittedSearch) } @Test fun `on cleared unregisters preference listener`() { playStoreSelected = true buildViewModel() assertNotNull(preferenceListener) invokeOnCleared() assertNull(preferenceListener) } private fun advanceDebounce() { mainCoroutineRule.testDispatcher.scheduler.advanceTimeBy(DEBOUNCE_MS) mainCoroutineRule.testDispatcher.scheduler.runCurrent() } private fun visibleTabs(): List<SearchTabType> = buildList { if (playStoreSelected) add(SearchTabType.COMMON_APPS) if (openSourceSelected) add(SearchTabType.OPEN_SOURCE) if (pwaSelected) add(SearchTabType.WEB_APPS) } private fun notifyPreferenceChange(key: String) { preferenceListener?.onSharedPreferenceChanged(null, key) } private fun buildViewModel() { viewModel = SearchViewModelV2(suggestionSource, preference, stores) } private fun invokeOnCleared() { val method = SearchViewModelV2::class.java.getDeclaredMethod("onCleared") method.isAccessible = true method.invoke(viewModel) } } Loading
app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +2 −2 Original line number Diff line number Diff line Loading @@ -152,7 +152,7 @@ class SearchViewModelV2 @Inject constructor( onSearchSubmitted(suggestion) } fun onClearQuery() { fun onQueryCleared() { suggestionJob?.cancel() _uiState.update { current -> if (current.hasSubmittedSearch && current.availableTabs.isNotEmpty()) { Loading @@ -179,7 +179,7 @@ class SearchViewModelV2 @Inject constructor( fun onSearchSubmitted(submitted: String) { val trimmedQuery = submitted.trim() if (trimmedQuery.isEmpty()) { onClearQuery() onQueryCleared() return } Loading
app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +235 −30 Original line number Diff line number Diff line Loading @@ -18,13 +18,24 @@ package foundation.e.apps.ui.search.v2 import android.content.SharedPreferences import foundation.e.apps.data.Constants.PREFERENCE_SHOW_FOSS import foundation.e.apps.data.Constants.PREFERENCE_SHOW_GPLAY import foundation.e.apps.data.Stores import foundation.e.apps.data.cleanapk.repositories.CleanApkAppsRepository import foundation.e.apps.data.cleanapk.repositories.CleanApkPwaRepository import foundation.e.apps.data.playstore.PlayStoreRepository import foundation.e.apps.data.preference.AppLoungePreference import foundation.e.apps.data.search.FakeSuggestionSource import foundation.e.apps.data.search.SuggestionSource import foundation.e.apps.util.MainCoroutineRule import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule Loading @@ -38,93 +49,287 @@ class SearchViewModelV2Test { @get:Rule val mainCoroutineRule = MainCoroutineRule() private lateinit var suggestionSource: SuggestionSource private lateinit var suggestionSource: FakeSuggestionSource private lateinit var preference: AppLoungePreference private lateinit var stores: Stores private var playStoreSelected = true private var openSourceSelected = true private var pwaSelected = false private var preferenceListener: SharedPreferences.OnSharedPreferenceChangeListener? = null private lateinit var viewModel: SearchViewModelV2 @Before fun setUp() { suggestionSource = FakeSuggestionSource() viewModel = SearchViewModelV2(suggestionSource) preference = mockk(relaxed = true) stores = buildStores() every { preference.isPlayStoreSelected() } answers { playStoreSelected } every { preference.isOpenSourceSelected() } answers { openSourceSelected } every { preference.isPWASelected() } answers { pwaSelected } every { preference.registerStorePreferenceListener(any()) } answers { preferenceListener = arg(0) Unit } every { preference.unregisterStorePreferenceListener(any()) } answers { if (preferenceListener == arg<SharedPreferences.OnSharedPreferenceChangeListener>(0)) { preferenceListener = null } Unit } buildViewModel() } private fun buildStores(): Stores { val playStoreRepository = mockk<PlayStoreRepository>(relaxed = true) val cleanApkAppsRepository = mockk<CleanApkAppsRepository>(relaxed = true) val cleanApkPwaRepository = mockk<CleanApkPwaRepository>(relaxed = true) return Stores( playStoreRepository, cleanApkAppsRepository, cleanApkPwaRepository, preference ) } @Test fun `non-blank query loads suggestions after debounce`() = runTest { viewModel.onQueryChanged("tel") fun `play store disabled hides suggestions when typing`() = runTest { playStoreSelected = false viewModel.onQueryChanged("apps") advanceDebounce() val state = viewModel.uiState.value assertEquals(listOf("Telegram", "Telegram FOSS", "Telegram X"), state.suggestions.take(3)) assertTrue(state.isSuggestionVisible) assertEquals("tel", state.query) assertTrue(state.suggestions.isEmpty()) assertFalse(state.isSuggestionVisible) assertEquals("apps", state.query) } @Test fun `blank query clears suggestions and hides immediately`() = runTest { fun `matching query shows suggestions when play store enabled`() = runTest { playStoreSelected = true viewModel.onQueryChanged("tel") advanceDebounce() val state = viewModel.uiState.value assertFalse(state.suggestions.isEmpty()) assertTrue(state.isSuggestionVisible) assertEquals("tel", state.query) } @Test fun `blank query before submit clears tabs and results`() = runTest { viewModel.onQueryChanged(" ") val state = viewModel.uiState.value assertTrue(state.suggestions.isEmpty()) assertTrue(state.resultsByTab.isEmpty()) assertEquals(visibleTabs(), state.availableTabs) assertEquals(visibleTabs().firstOrNull(), state.selectedTab) assertFalse(state.hasSubmittedSearch) } @Test fun `blank query after submit hides suggestions but keeps results`() = runTest { viewModel.onSearchSubmitted("query") val resultsBefore = viewModel.uiState.value.resultsByTab val tabsBefore = viewModel.uiState.value.availableTabs viewModel.onQueryChanged(" ") val state = viewModel.uiState.value assertEquals(resultsBefore, state.resultsByTab) assertEquals(tabsBefore, state.availableTabs) assertTrue(state.hasSubmittedSearch) assertFalse(state.isSuggestionVisible) assertEquals("", state.query) } @Test fun `empty suggestions keep dropdown hidden`() = runTest { viewModel.onQueryChanged("zzz") fun `clear query after submit retains tabs and results`() = runTest { viewModel.onSearchSubmitted("query") val resultsBefore = viewModel.uiState.value.resultsByTab val tabsBefore = viewModel.uiState.value.availableTabs advanceDebounce() viewModel.onQueryCleared() val state = viewModel.uiState.value assertEquals(resultsBefore, state.resultsByTab) assertEquals(tabsBefore, state.availableTabs) assertTrue(state.hasSubmittedSearch) assertEquals("", state.query) assertFalse(state.isSuggestionVisible) } @Test fun `search submit trims query and builds per tab results`() = runTest { playStoreSelected = true openSourceSelected = true pwaSelected = true buildViewModel() viewModel.onSearchSubmitted(" spaced query ") val state = viewModel.uiState.value assertEquals("spaced query", state.query) assertEquals(visibleTabs(), state.availableTabs) assertTrue(state.resultsByTab.keys.containsAll(visibleTabs())) assertTrue(state.resultsByTab[SearchTabType.WEB_APPS]!!.all { it.contains("spaced query") }) assertTrue(state.resultsByTab.values.all { it.size == 6 }) assertTrue(state.hasSubmittedSearch) assertTrue(state.suggestions.isEmpty()) assertFalse(state.isSuggestionVisible) assertEquals("zzz", state.query) } @Test fun `clear query resets state`() = runTest { viewModel.onQueryChanged("sig") advanceDebounce() fun `search submit with no visible tabs yields no results`() = runTest { playStoreSelected = false openSourceSelected = false pwaSelected = false buildViewModel() viewModel.onSearchSubmitted("anything") viewModel.onQueryCleared() val state = viewModel.uiState.value assertTrue(state.availableTabs.isEmpty()) assertTrue(state.resultsByTab.isEmpty()) assertNull(state.selectedTab) assertFalse(state.hasSubmittedSearch) } @Test fun `search submit with blank query clears state`() = runTest { playStoreSelected = true buildViewModel() viewModel.onSearchSubmitted(" ") val state = viewModel.uiState.value assertTrue(state.resultsByTab.isEmpty()) assertFalse(state.hasSubmittedSearch) assertEquals(visibleTabs(), state.availableTabs) assertEquals(visibleTabs().firstOrNull(), state.selectedTab) assertEquals("", state.query) assertTrue(state.suggestions.isEmpty()) assertFalse(state.isSuggestionVisible) } @Test fun `selecting suggestion commits value and hides dropdown`() = runTest { fun `store change after submit rebuilds tabs and results`() = runTest { playStoreSelected = true openSourceSelected = false pwaSelected = false buildViewModel() viewModel.onSearchSubmitted("apps") playStoreSelected = false openSourceSelected = true notifyPreferenceChange(PREFERENCE_SHOW_FOSS) val state = viewModel.uiState.value assertEquals(listOf(SearchTabType.OPEN_SOURCE), state.availableTabs) assertEquals(SearchTabType.OPEN_SOURCE, state.selectedTab) assertTrue(state.resultsByTab.keys == setOf(SearchTabType.OPEN_SOURCE)) assertTrue(state.resultsByTab[SearchTabType.OPEN_SOURCE]!!.all { it.contains("apps") }) assertTrue(state.hasSubmittedSearch) } @Test fun `store change hides suggestions when play store turns off`() = runTest { playStoreSelected = true buildViewModel() viewModel.onQueryChanged("tel") advanceDebounce() assertTrue(viewModel.uiState.value.isSuggestionVisible) viewModel.onSuggestionSelected("Telegram X") playStoreSelected = false notifyPreferenceChange(PREFERENCE_SHOW_GPLAY) 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() fun `store change removing all tabs clears submitted state`() = runTest { playStoreSelected = true openSourceSelected = false pwaSelected = false buildViewModel() viewModel.onSearchSubmitted("apps") viewModel.onSearchSubmitted("Signal") playStoreSelected = false openSourceSelected = false pwaSelected = false notifyPreferenceChange(PREFERENCE_SHOW_GPLAY) val state = viewModel.uiState.value assertTrue(state.availableTabs.isEmpty()) assertTrue(state.resultsByTab.isEmpty()) assertNull(state.selectedTab) assertFalse(state.hasSubmittedSearch) } @Test fun `tab selection ignores unavailable tabs`() = runTest { playStoreSelected = true openSourceSelected = true pwaSelected = false buildViewModel() viewModel.onTabSelected(SearchTabType.WEB_APPS) assertEquals(SearchTabType.COMMON_APPS, viewModel.uiState.value.selectedTab) viewModel.onTabSelected(SearchTabType.OPEN_SOURCE) assertEquals(SearchTabType.OPEN_SOURCE, viewModel.uiState.value.selectedTab) } @Test fun `on suggestion selected delegates to search submission`() = runTest { playStoreSelected = true buildViewModel() viewModel.onSuggestionSelected("Signal ") val state = viewModel.uiState.value assertEquals("Signal", state.query) assertTrue(state.suggestions.isEmpty()) assertFalse(state.isSuggestionVisible) assertTrue(state.hasSubmittedSearch) } @Test fun `on cleared unregisters preference listener`() { playStoreSelected = true buildViewModel() assertNotNull(preferenceListener) invokeOnCleared() assertNull(preferenceListener) } private fun advanceDebounce() { mainCoroutineRule.testDispatcher.scheduler.advanceTimeBy(DEBOUNCE_MS) mainCoroutineRule.testDispatcher.scheduler.runCurrent() } private fun visibleTabs(): List<SearchTabType> = buildList { if (playStoreSelected) add(SearchTabType.COMMON_APPS) if (openSourceSelected) add(SearchTabType.OPEN_SOURCE) if (pwaSelected) add(SearchTabType.WEB_APPS) } private fun notifyPreferenceChange(key: String) { preferenceListener?.onSharedPreferenceChanged(null, key) } private fun buildViewModel() { viewModel = SearchViewModelV2(suggestionSource, preference, stores) } private fun invokeOnCleared() { val method = SearchViewModelV2::class.java.getDeclaredMethod("onCleared") method.isAccessible = true method.invoke(viewModel) } }