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

Commit dc3ab457 authored by Ellen Poe's avatar Ellen Poe
Browse files

test: more viewmodel tests

parent a8971ec9
Loading
Loading
Loading
Loading
Loading
+0 −18
Original line number Diff line number Diff line
@@ -28,14 +28,11 @@ import earth.maps.cardinal.data.Place
import earth.maps.cardinal.data.room.SavedPlaceDao
import earth.maps.cardinal.transit.StopTime
import earth.maps.cardinal.transit.TransitousService
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds

@HiltViewModel
class TransitStopCardViewModel @Inject constructor(
@@ -44,8 +41,6 @@ class TransitStopCardViewModel @Inject constructor(
    private val appPreferenceRepository: AppPreferenceRepository,
) : ViewModel() {

    private var refreshJob: Job? = null

    val isPlaceSaved = mutableStateOf(false)
    val stop = mutableStateOf<Place?>(null)
    val departures = MutableStateFlow<List<StopTime>>(emptyList())
@@ -73,15 +68,6 @@ class TransitStopCardViewModel @Inject constructor(

    val use24HourFormat = appPreferenceRepository.use24HourFormat

    init {
        refreshJob = viewModelScope.launch {
            while (true) {
                refreshDepartures()
                delay(30.seconds)
            }
        }
    }

    fun setStop(place: Place) {
        this.stop.value = place
    }
@@ -139,10 +125,6 @@ class TransitStopCardViewModel @Inject constructor(
        }
    }

    override fun onCleared() {
        super.onCleared()
        refreshJob?.cancel()
    }

    companion object {
        private const val TAG = "TransitStopViewModel"
+52 −0
Original line number Diff line number Diff line
/*
 *     Cardinal Maps
 *     Copyright (C) 2025 Cardinal Maps Authors
 *
 *     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 earth.maps.cardinal.ui.directions

import earth.maps.cardinal.routing.FerrostarWrapperRepository
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class TurnByTurnNavigationViewModelTest {

    private lateinit var viewModel: TurnByTurnNavigationViewModel
    private lateinit var mockFerrostarWrapperRepository: FerrostarWrapperRepository

    @Before
    fun setup() {
        mockFerrostarWrapperRepository = mockk(relaxed = true)
        viewModel = TurnByTurnNavigationViewModel(
            ferrostarWrapperRepository = mockFerrostarWrapperRepository
        )
    }

    @Test
    fun `viewModel should have correct ferrostarWrapperRepository`() {
        assertEquals(mockFerrostarWrapperRepository, viewModel.ferrostarWrapperRepository)
    }

    @Test
    fun `viewModel should be instance of ViewModel`() {
        assertEquals(true, viewModel is androidx.lifecycle.ViewModel)
    }
}
 No newline at end of file
+301 −0
Original line number Diff line number Diff line
/*
 *     Cardinal Maps
 *     Copyright (C) 2025 Cardinal Maps Authors
 *
 *     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 earth.maps.cardinal.ui.home

import androidx.compose.ui.text.input.TextFieldValue
import earth.maps.cardinal.MainCoroutineRule
import earth.maps.cardinal.data.LatLng
import earth.maps.cardinal.data.Place
import earth.maps.cardinal.data.ViewportRepository
import earth.maps.cardinal.data.room.RecentSearch
import earth.maps.cardinal.data.room.RecentSearchRepository
import earth.maps.cardinal.data.room.SavedPlace
import earth.maps.cardinal.data.room.SavedPlaceDao
import earth.maps.cardinal.data.room.SavedPlaceRepository
import earth.maps.cardinal.geocoding.GeocodingService
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
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
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class HomeViewModelTest {

    @ExperimentalCoroutinesApi
    @get:Rule
    var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher())

    private lateinit var viewModel: HomeViewModel
    private lateinit var mockPlaceDao: SavedPlaceDao
    private lateinit var mockGeocodingService: GeocodingService
    private lateinit var mockViewportRepository: ViewportRepository
    private lateinit var mockLocationRepository: earth.maps.cardinal.data.LocationRepository
    private lateinit var mockSavedPlaceRepository: SavedPlaceRepository
    private lateinit var mockRecentSearchRepository: RecentSearchRepository

    private val testPlace = Place(
        id = "test-place-id",
        name = "Test Place",
        latLng = LatLng(37.7749, -122.4194)
    )

    private val testSavedPlace = SavedPlace(
        id = "test-saved-place-id",
        placeId = null,
        name = "Test Saved Place",
        type = "place",
        icon = "place",
        latitude = 37.7749,
        longitude = -122.4194,
        createdAt = System.currentTimeMillis(),
        updatedAt = System.currentTimeMillis(),
        isPinned = true
    )

    private val testRecentSearch = RecentSearch(
        id = "test-recent-search-id",
        name = "Test Recent Search",
        description = "place",
        icon = "place",
        latitude = 37.7749,
        longitude = -122.4194,
        tappedAt = System.currentTimeMillis()
    )

    @Before
    fun setup() {
        mockPlaceDao = mockk()
        mockGeocodingService = mockk()
        mockViewportRepository = mockk()
        mockLocationRepository = mockk()
        mockSavedPlaceRepository = mockk()
        mockRecentSearchRepository = mockk()

        // Mock default flows
        every { mockPlaceDao.getAllPlacesAsFlow() } returns MutableStateFlow(emptyList())
        every { mockRecentSearchRepository.getRecentSearches() } returns emptyFlow()
        every { mockViewportRepository.viewportCenter } returns MutableStateFlow(LatLng(37.7749, -122.4194))
        every { mockSavedPlaceRepository.toPlace(any()) } returns testPlace
        every { mockRecentSearchRepository.toPlace(any()) } returns testPlace

        viewModel = HomeViewModel(
            placeDao = mockPlaceDao,
            geocodingService = mockGeocodingService,
            viewportRepository = mockViewportRepository,
            locationRepository = mockLocationRepository,
            savedPlaceRepository = mockSavedPlaceRepository,
            recentSearchRepository = mockRecentSearchRepository
        )
    }

    @Test
    fun `initial state should be correct`() {
        assertEquals(TextFieldValue(), viewModel.searchQuery)
        assertEquals(emptyList<Place>(), viewModel.geocodeResults.value)
        assertFalse(viewModel.isSearching)
        assertNull(viewModel.searchError)
    }

    @Test
    fun `updateSearchQuery should update searchQuery and searchQueryFlow`() {
        val newQuery = TextFieldValue("Test Query")
        
        viewModel.updateSearchQuery(newQuery)
        
        assertEquals(newQuery, viewModel.searchQuery)
    }

    @Test
    fun `updateSearchQuery with empty query should clear results`() = runTest {
        // First set a non-empty query
        viewModel.updateSearchQuery(TextFieldValue("Test"))
        advanceUntilIdle()
        
        // Then set empty query
        viewModel.updateSearchQuery(TextFieldValue(""))
        advanceUntilIdle()
        
        assertEquals(emptyList<Place>(), viewModel.geocodeResults.value)
        assertNull(viewModel.searchError)
    }

    @Test
    fun `performSearch should update geocodeResults on success`() = runTest {
        val query = "Test Query"
        val expectedResults = listOf(testPlace)
        
        coEvery { 
            mockGeocodingService.geocode(query, any()) 
        } returns expectedResults
        
        viewModel.updateSearchQuery(TextFieldValue(query))
        advanceUntilIdle()
        
        assertEquals(expectedResults, viewModel.geocodeResults.value)
        assertFalse(viewModel.isSearching)
        assertNull(viewModel.searchError)
    }

    @Test
    fun `performSearch should handle error and set searchError`() = runTest {
        val query = "Test Query"
        val errorMessage = "Search failed"
        
        coEvery { 
            mockGeocodingService.geocode(query, any()) 
        } throws Exception(errorMessage)
        
        viewModel.updateSearchQuery(TextFieldValue(query))
        advanceUntilIdle()
        
        assertEquals(emptyList<Place>(), viewModel.geocodeResults.value)
        assertFalse(viewModel.isSearching)
        assertEquals(errorMessage, viewModel.searchError)
    }

    @Test
    fun `pinnedPlaces should return only pinned places`() = runTest {
        val pinnedPlace = testSavedPlace.copy(isPinned = true)
        val unpinnedPlace = testSavedPlace.copy(isPinned = false, id = "unpinned")
        val allPlaces = listOf(pinnedPlace, unpinnedPlace)
        
        every { mockPlaceDao.getAllPlacesAsFlow() } returns MutableStateFlow(allPlaces)
        every { mockSavedPlaceRepository.toPlace(pinnedPlace) } returns testPlace
        every { mockSavedPlaceRepository.toPlace(unpinnedPlace) } returns testPlace.copy(id = "unpinned")
        
        val result = viewModel.pinnedPlaces()
        
        // Since it returns a Flow, we need to collect it with take(1)
        val collectedResult = result.take(1).toList()
        
        assertEquals(1, collectedResult.size)
        assertEquals(1, collectedResult[0].size)
        assertEquals(testPlace, collectedResult[0][0])
    }

    @Test
    fun `expandSearch should set searchExpanded to true`() = runTest {
        // Collect the flow with take(1) after the action
        viewModel.expandSearch()
        
        val collectedResult = viewModel.searchExpanded.take(1).toList()
        
        assertEquals(1, collectedResult.size)
        assertTrue(collectedResult[0])
    }

    @Test
    fun `collapseSearch should set searchExpanded to false`() = runTest {
        // First expand
        viewModel.expandSearch()
        
        // Then collapse
        viewModel.collapseSearch()
        
        val collectedResult = viewModel.searchExpanded.take(1).toList()
        
        assertEquals(1, collectedResult.size)
        assertFalse(collectedResult[0])
    }

    @Test
    fun `onPlaceSelected should add to recent searches`() = runTest {
        coEvery { mockRecentSearchRepository.addRecentSearch(testPlace) } returns Result.success(Unit)
        
        viewModel.onPlaceSelected(testPlace)
        advanceUntilIdle()
        
        coVerify { mockRecentSearchRepository.addRecentSearch(testPlace) }
    }

    @Test
    fun `recentSearches should return recent searches flow`() = runTest {
        val expectedRecentSearches = listOf(testRecentSearch)
        every { mockRecentSearchRepository.getRecentSearches() } returns MutableStateFlow(expectedRecentSearches)
        
        val result = viewModel.recentSearches()
        
        // Since it returns a Flow, we need to collect it with take(1)
        val collectedResult = result.take(1).toList()
        
        assertEquals(1, collectedResult.size)
        assertEquals(expectedRecentSearches, collectedResult[0])
    }

    @Test
    fun `searchToPlace should convert recent search to place`() {
        every { mockRecentSearchRepository.toPlace(testRecentSearch) } returns testPlace
        
        val result = viewModel.searchToPlace(testRecentSearch)
        
        assertEquals(testPlace, result)
    }

    @Test
    fun `removeRecentSearch should call repository removeRecentSearch`() = runTest {
        coEvery { mockRecentSearchRepository.removeRecentSearch(testRecentSearch) } returns Unit
        
        viewModel.removeRecentSearch(testRecentSearch)
        advanceUntilIdle()
        
        coVerify { mockRecentSearchRepository.removeRecentSearch(testRecentSearch) }
    }

    @Test
    fun `search should be debounced`() = runTest {
        val query1 = "Test1"
        val query2 = "Test2"
        val expectedResults = listOf(testPlace)
        
        coEvery { 
            mockGeocodingService.geocode(query2, any()) 
        } returns expectedResults
        
        // Update query twice quickly
        viewModel.updateSearchQuery(TextFieldValue(query1))
        viewModel.updateSearchQuery(TextFieldValue(query2))
        
        // Only the second query should trigger search due to debouncing
        advanceUntilIdle()
        
        coVerify { mockGeocodingService.geocode(query2, any()) }
        coVerify(inverse = true) { mockGeocodingService.geocode(query1, any()) }
        assertEquals(expectedResults, viewModel.geocodeResults.value)
    }
}
 No newline at end of file
+303 −0

File added.

Preview size limit exceeded, changes collapsed.

+355 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading