diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ed9be72dedc48963b7980667063bf3310d8efa3a..63398a08f88be9be32a4556ab7f30ba615bfa1a4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -160,6 +160,7 @@ test: # Build debug APKs for each architecture - cd cardinal-android - touch local.properties + - ./gradlew generateUniFFIBindings - ./gradlew test --info --stacktrace --no-daemon - ./gradlew assembleArm64Debug --info --stacktrace --no-daemon artifacts: @@ -257,6 +258,7 @@ build_release: - touch local.properties - | # Build APKs for each architecture + ./gradlew generateUniFFIBindings ./gradlew assembleArm64Release --info --stacktrace --no-daemon ./gradlew assembleX86_64Release --info --stacktrace --no-daemon artifacts: diff --git a/cardinal-android/app/build.gradle.kts b/cardinal-android/app/build.gradle.kts index 695483a5e66373a617578605ffee10c453eb850f..5b83551aa2eba4aa2931f882a536820a37013618 100644 --- a/cardinal-android/app/build.gradle.kts +++ b/cardinal-android/app/build.gradle.kts @@ -137,15 +137,6 @@ android { dependsOn("buildCargoNdkArm64Release", "buildCargoNdkX86_64Release") } - applicationVariants.all { - val variant = this - - // Add dependency from Java compilation to the UniFFI binding generation task - tasks.named("compile${variant.name.capitalize()}JavaWithJavac") { - dependsOn(generateUniFFIBindings) - } - } - sourceSets { getByName("main") { java.srcDir(layout.buildDirectory.dir("generated/source/uniffi")) diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/OfflineAreasViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/OfflineAreasViewModel.kt index c2840c47f38540ac6c9200139f59af44efc47d7d..00924c6ab88c585729e26e1ea73da8c27bf9ff87 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/OfflineAreasViewModel.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/OfflineAreasViewModel.kt @@ -23,7 +23,6 @@ import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.os.IBinder -import android.util.Log import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -39,6 +38,8 @@ import earth.maps.cardinal.tileserver.TileDownloadForegroundService import earth.maps.cardinal.tileserver.calculateTileRange import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.math.min @@ -60,6 +61,10 @@ class OfflineAreasViewModel @Inject constructor( val unifiedProgress = mutableFloatStateOf(0f) // 0.0 to 1.0 val currentStage = mutableStateOf(DownloadStage.BASEMAP) + // Error handling + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage + // Service binding infrastructure private var serviceBinder: TileDownloadForegroundService.TileDownloadBinder? = null private var progressJob: Job? = null @@ -67,14 +72,12 @@ class OfflineAreasViewModel @Inject constructor( private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - Log.d(TAG, "Connected to TileDownloadForegroundService") serviceBinder = service as TileDownloadForegroundService.TileDownloadBinder isBound = true syncWithOngoingDownloads() } override fun onServiceDisconnected(name: ComponentName?) { - Log.d(TAG, "Disconnected from TileDownloadForegroundService") serviceBinder = null isBound = false progressJob?.cancel() @@ -84,7 +87,6 @@ class OfflineAreasViewModel @Inject constructor( } override fun onBindingDied(name: ComponentName?) { - Log.d(TAG, "Binding to TileDownloadForegroundService died") serviceBinder = null isBound = false progressJob?.cancel() @@ -93,7 +95,6 @@ class OfflineAreasViewModel @Inject constructor( } override fun onNullBinding(name: ComponentName?) { - Log.d(TAG, "Null binding to TileDownloadForegroundService") serviceBinder = null isBound = false resetProgressState() @@ -118,13 +119,8 @@ class OfflineAreasViewModel @Inject constructor( // Check if service is currently downloading val isServiceDownloading = service.isDownloading.value if (isServiceDownloading) { - Log.d(TAG, "Detected ongoing download in service, syncing UI state") // The StateFlow observations should handle the rest - } else { - Log.d(TAG, "No ongoing downloads detected in service") } - } else { - Log.d(TAG, "Service not bound yet, will sync when connection established") } } } @@ -179,30 +175,36 @@ class OfflineAreasViewModel @Inject constructor( return totalTiles } - /** + /** * Bind to the TileDownloadForegroundService */ - private fun bindToService() { - if (!isBound) { - Log.d(TAG, "Binding to TileDownloadForegroundService") - val intent = Intent(context, TileDownloadForegroundService::class.java) - context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) - } - } - - /** + private fun bindToService() { + if (!isBound) { + val intent = Intent(context, TileDownloadForegroundService::class.java) + try { + context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + } catch (e: Exception) { + _errorMessage.value = "Failed to bind to download service: ${e.message}" + } + } + } + + /** * Unbind from the TileDownloadForegroundService */ - private fun unbindFromService() { - if (isBound) { - Log.d(TAG, "Unbinding from TileDownloadForegroundService") - context.unbindService(serviceConnection) - serviceBinder = null - isBound = false - } - progressJob?.cancel() - progressJob = null - } + private fun unbindFromService() { + if (isBound) { + try { + context.unbindService(serviceConnection) + } catch (e: Exception) { + _errorMessage.value = "Failed to unbind from download service: ${e.message}" + } + serviceBinder = null + isBound = false + } + progressJob?.cancel() + progressJob = null + } /** * Reset progress state when service is not available @@ -215,15 +217,12 @@ class OfflineAreasViewModel @Inject constructor( } if (!hasActiveAreas) { - // No downloading or processing areas in database - isDownloading.value = false - downloadProgress.intValue = 0 - totalTiles.intValue = 0 - currentAreaName.value = "" - Log.d(TAG, "Reset progress state - no active downloads in database") - } else { - Log.d(TAG, "Not resetting progress state - active areas exist in database") - } + // No downloading or processing areas in database + isDownloading.value = false + downloadProgress.intValue = 0 + totalTiles.intValue = 0 + currentAreaName.value = "" + } } /** @@ -237,6 +236,12 @@ class OfflineAreasViewModel @Inject constructor( companion object { const val OFFLINE_AREA_MIN_ZOOM = 5 const val OFFLINE_AREA_MAX_ZOOM = 14 - private const val TAG = "OfflineAreasViewModel" + } + + /** + * Clear any error message + */ + fun clearErrorMessage() { + _errorMessage.value = null } } diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/TransitStopCardViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/TransitStopCardViewModel.kt index 454a49a7f5470376d506efb698945c118ad84d99..61a861accf918ec87ead65904ab5d25f0dd5240c 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/TransitStopCardViewModel.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/TransitStopCardViewModel.kt @@ -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(null) val departures = MutableStateFlow>(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" diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/SettingsViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/SettingsViewModel.kt index 8ffdf5b0ffda7eea4c49d723bd9aeba2566bc7fb..00741f01ab6874e0d4093c71ab4a94cd82742e83 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/SettingsViewModel.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/SettingsViewModel.kt @@ -158,12 +158,4 @@ class SettingsViewModel @Inject constructor( } return null } - - fun onCallToActionClicked() { - val url = "https://github.com/ellenhp/cardinal" - val i = Intent(Intent.ACTION_VIEW) - i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - i.setData(url.toUri()) - context.startActivity(i) - } } diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/directions/TurnByTurnNavigationViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/directions/TurnByTurnNavigationViewModelTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..962f027bdf8f37f629519dad42ce755427d7a352 --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/directions/TurnByTurnNavigationViewModelTest.kt @@ -0,0 +1,47 @@ +/* + * 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 . + */ + +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) + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/home/HomeViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/home/HomeViewModelTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f2c58ebb882bd5efb26efb73e36405e8861705f7 --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/home/HomeViewModelTest.kt @@ -0,0 +1,301 @@ +/* + * 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 . + */ + +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() + + 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(), 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(), 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(), 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 diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/home/OfflineAreasViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/home/OfflineAreasViewModelTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..0779b763ab37bbb71280745f601b85a0d49345c8 --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/home/OfflineAreasViewModelTest.kt @@ -0,0 +1,324 @@ +/* + * 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 . + */ + +package earth.maps.cardinal.ui.home + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import earth.maps.cardinal.MainCoroutineRule +import earth.maps.cardinal.data.BoundingBox +import earth.maps.cardinal.data.room.DownloadStatus +import earth.maps.cardinal.data.room.OfflineArea +import earth.maps.cardinal.data.room.OfflineAreaRepository +import earth.maps.cardinal.tileserver.TileDownloadForegroundService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +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 +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class OfflineAreasViewModelTest { + + @ExperimentalCoroutinesApi + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + private lateinit var viewModel: OfflineAreasViewModel + private lateinit var mockContext: Context + private lateinit var mockOfflineAreaRepository: OfflineAreaRepository + private lateinit var mockServiceBinder: TileDownloadForegroundService.TileDownloadBinder + private lateinit var mockService: TileDownloadForegroundService + + private val testBoundingBox = BoundingBox( + north = 37.8, + south = 37.7, + east = -122.4, + west = -122.5 + ) + + private val testOfflineArea = OfflineArea( + id = "test-area-id", + name = "Test Area", + north = 37.8, + south = 37.7, + east = -122.4, + west = -122.5, + minZoom = 5, + maxZoom = 14, + downloadDate = System.currentTimeMillis(), + fileSize = 2000000L, + status = DownloadStatus.COMPLETED + ) + + private val testIncompleteOfflineArea = OfflineArea( + id = "test-incomplete-area-id", + name = "Test Incomplete Area", + north = 37.8, + south = 37.7, + east = -122.4, + west = -122.5, + minZoom = 5, + maxZoom = 14, + downloadDate = System.currentTimeMillis(), + fileSize = 1000000L, + status = DownloadStatus.DOWNLOADING_BASEMAP + ) + + @Before + fun setup() { + mockContext = mockk(relaxed = true) + mockOfflineAreaRepository = mockk() + mockServiceBinder = mockk(relaxed = true) + mockService = mockk(relaxed = true) + + // Mock the repository flow + every { mockOfflineAreaRepository.getAllOfflineAreas() } returns MutableStateFlow(emptyList()) + + // Mock the service binder + every { mockServiceBinder.getService() } returns mockService + every { mockService.isDownloading } returns MutableStateFlow(false) + + viewModel = OfflineAreasViewModel( + context = mockContext, + offlineAreaRepository = mockOfflineAreaRepository + ) + } + + @After + fun tearDown() { + // No cleanup needed + } + + @Test + fun `initial state should be correct`() { + assertTrue(viewModel.offlineAreas.value.isEmpty()) + assertFalse(viewModel.isDownloading.value) + assertFalse(viewModel.isPaused.value) + assertEquals(0, viewModel.downloadProgress.intValue) + assertEquals(0, viewModel.totalTiles.intValue) + assertEquals("", viewModel.currentAreaName.value) + assertEquals(0f, viewModel.unifiedProgress.floatValue) + assertEquals(earth.maps.cardinal.tileserver.DownloadStage.BASEMAP, viewModel.currentStage.value) + } + + @Test + fun `loadOfflineAreas should update offlineAreas state`() = runTest { + val areasFlow = MutableStateFlow(listOf(testOfflineArea)) + every { mockOfflineAreaRepository.getAllOfflineAreas() } returns areasFlow + + // Create a new ViewModel to trigger init + viewModel = OfflineAreasViewModel( + context = mockContext, + offlineAreaRepository = mockOfflineAreaRepository + ) + + advanceUntilIdle() + + assertEquals(listOf(testOfflineArea), viewModel.offlineAreas.value) + } + + @Test + fun `startDownload should call service startDownload with correct parameters`() = runTest { + val testName = "Test Download Area" + + // Manually set the service binder to simulate service connection + val serviceBinderField = OfflineAreasViewModel::class.java.getDeclaredField("serviceBinder") + serviceBinderField.isAccessible = true + serviceBinderField.set(viewModel, mockServiceBinder) + + val isBoundField = OfflineAreasViewModel::class.java.getDeclaredField("isBound") + isBoundField.isAccessible = true + isBoundField.set(viewModel, true) + + viewModel.startDownload(testBoundingBox, testName) + + verify { + mockService.startDownload( + testBoundingBox, + OfflineAreasViewModel.OFFLINE_AREA_MIN_ZOOM, + OfflineAreasViewModel.OFFLINE_AREA_MAX_ZOOM, + testName + ) + } + } + + @Test + fun `deleteOfflineArea should call service deleteTilesForArea and repository deleteOfflineArea`() = runTest { + // Mock the deleteOfflineArea method + coEvery { mockOfflineAreaRepository.deleteOfflineArea(any()) } returns Unit + + // Manually set the service binder to simulate service connection + val serviceBinderField = OfflineAreasViewModel::class.java.getDeclaredField("serviceBinder") + serviceBinderField.isAccessible = true + serviceBinderField.set(viewModel, mockServiceBinder) + + val isBoundField = OfflineAreasViewModel::class.java.getDeclaredField("isBound") + isBoundField.isAccessible = true + isBoundField.set(viewModel, true) + + viewModel.deleteOfflineArea(testOfflineArea) + advanceUntilIdle() + + verify { mockService.deleteTilesForArea(testOfflineArea.id) } + coVerify { mockOfflineAreaRepository.deleteOfflineArea(testOfflineArea) } + } + + @Test + fun `estimateTileCount should calculate correct number of tiles`() { + val result = viewModel.estimateTileCount(testBoundingBox, 5, 14) + + // The exact calculation depends on the calculateTileRange implementation + // We're just verifying it returns a positive number + assertTrue(result > 0) + } + + @Test + fun `estimateTileCount with max zoom above 14 should limit to 14`() { + val result = viewModel.estimateTileCount(testBoundingBox, 5, 16) + + // Should use 14 as the max zoom + assertTrue(result > 0) + } + + @Test + fun `resetProgressState with no active areas should reset progress`() = runTest { + // Set some initial state + viewModel.isDownloading.value = true + viewModel.downloadProgress.intValue = 50 + viewModel.totalTiles.intValue = 100 + viewModel.currentAreaName.value = "Test Area" + + // Set offline areas to empty list (no active areas) + val areasFlow = MutableStateFlow(emptyList()) + every { mockOfflineAreaRepository.getAllOfflineAreas() } returns areasFlow + + // Create a new ViewModel to trigger init and reset + viewModel = OfflineAreasViewModel( + context = mockContext, + offlineAreaRepository = mockOfflineAreaRepository + ) + + advanceUntilIdle() + + // State should be reset + assertFalse(viewModel.isDownloading.value) + assertEquals(0, viewModel.downloadProgress.intValue) + assertEquals(0, viewModel.totalTiles.intValue) + assertEquals("", viewModel.currentAreaName.value) + } + + @Test + fun `resetProgressState with active incomplete areas should not reset progress`() = runTest { + // Set some initial state + viewModel.isDownloading.value = true + viewModel.downloadProgress.intValue = 50 + viewModel.totalTiles.intValue = 100 + viewModel.currentAreaName.value = "Test Area" + + // Set offline areas to include an incomplete area + val areasFlow = MutableStateFlow(listOf(testIncompleteOfflineArea)) + every { mockOfflineAreaRepository.getAllOfflineAreas() } returns areasFlow + + // Create a new ViewModel to trigger init + viewModel = OfflineAreasViewModel( + context = mockContext, + offlineAreaRepository = mockOfflineAreaRepository + ) + + advanceUntilIdle() + + // State should not be reset because there's an active area + // Note: The actual reset happens in a private method, so we're testing the overall behavior + } + + + @Test + fun `service connection should handle onServiceConnected correctly`() = runTest { + // Create a mock binder that can be cast to TileDownloadBinder + val mockBinder = mockk() + every { mockBinder.getService() } returns mockService + + val serviceConnection = viewModel.javaClass.getDeclaredField("serviceConnection").apply { + isAccessible = true + }.get(viewModel) as ServiceConnection + + serviceConnection.onServiceConnected(ComponentName("test", "test"), mockBinder) + + // Verify that the service binder was set correctly by checking if we can access the service + val serviceBinderField = OfflineAreasViewModel::class.java.getDeclaredField("serviceBinder") + serviceBinderField.isAccessible = true + val actualBinder = serviceBinderField.get(viewModel) + assertEquals(mockBinder, actualBinder) + } + + @Test + fun `service connection should handle onServiceDisconnected correctly`() = runTest { + val serviceConnection = viewModel.javaClass.getDeclaredField("serviceConnection").apply { + isAccessible = true + }.get(viewModel) as ServiceConnection + + serviceConnection.onServiceDisconnected(ComponentName("test", "test")) + + // Verify that the service binder was set to null + val serviceBinderField = OfflineAreasViewModel::class.java.getDeclaredField("serviceBinder") + serviceBinderField.isAccessible = true + val actualBinder = serviceBinderField.get(viewModel) + assertEquals(null, actualBinder) + } + + @Test + fun `errorMessage should be null initially`() { + assertEquals(null, viewModel.errorMessage.value) + } + + @Test + fun `clearErrorMessage should set errorMessage to null`() = runTest { + // First set an error message + val errorMessageField = OfflineAreasViewModel::class.java.getDeclaredField("_errorMessage") + errorMessageField.isAccessible = true + val errorFlow = errorMessageField.get(viewModel) as MutableStateFlow + errorFlow.value = "Test error" + + assertEquals("Test error", viewModel.errorMessage.value) + + // Clear the error + viewModel.clearErrorMessage() + assertEquals(null, viewModel.errorMessage.value) + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/home/TransitScreenViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/home/TransitScreenViewModelTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..8410355f31c9b42fec43a73f4e38c724a3bb6cfa --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/home/TransitScreenViewModelTest.kt @@ -0,0 +1,342 @@ +/* + * 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 . + */ + +package earth.maps.cardinal.ui.home + +import android.content.Context +import android.location.Location +import earth.maps.cardinal.MainCoroutineRule +import earth.maps.cardinal.data.AppPreferenceRepository +import earth.maps.cardinal.data.LatLng +import earth.maps.cardinal.data.LocationRepository +import earth.maps.cardinal.transit.StopPlace +import earth.maps.cardinal.transit.StopTime +import earth.maps.cardinal.transit.StopTimesResponse +import earth.maps.cardinal.transit.TransitStop +import earth.maps.cardinal.transit.TransitousService +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.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +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 TransitScreenViewModelTest { + + @ExperimentalCoroutinesApi + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + private lateinit var viewModel: TransitScreenViewModel + private lateinit var mockContext: Context + private lateinit var mockLocationRepository: LocationRepository + private lateinit var mockTransitousService: TransitousService + private lateinit var mockAppPreferenceRepository: AppPreferenceRepository + + private val testLocation = Location("test").apply { + latitude = 37.7749 + longitude = -122.4194 + } + + private val testLatLng = LatLng(37.7749, -122.4194) + + private val testTransitStop = TransitStop( + type = "STOP", + tokens = listOf("test"), + name = "Test Stop", + id = "test-stop-id", + lat = 37.7749, + lon = -122.4194, + level = 0.0, + tz = "America/Los_Angeles", + areas = emptyList(), + score = 1.0 + ) + + private val testStopPlace = StopPlace( + name = "Test Stop", + stopId = "test-stop-id", + lat = 37.7749, + lon = -122.4194, + level = 0.0, + tz = "America/Los_Angeles", + vertexType = "TRANSIT" + ) + + private val testStopTime = StopTime( + place = testStopPlace, + mode = "BUS", + realTime = true, + headsign = "Downtown", + agencyId = "agency-1", + agencyName = "Test Agency", + agencyUrl = "https://example.com", + routeColor = "FF0000", + tripId = "trip-1", + routeType = 3, + routeShortName = "42", + routeLongName = "Test Route", + tripShortName = null, + displayName = "42 - Downtown", + pickupDropoffType = "NORMAL", + cancelled = false, + tripCancelled = false, + source = "gtfs" + ) + + private val testStopTimesResponse = StopTimesResponse( + stopTimes = listOf(testStopTime), + place = testStopPlace + ) + + @Before + fun setup() { + mockContext = mockk() + mockLocationRepository = mockk() + mockTransitousService = mockk() + mockAppPreferenceRepository = mockk() + + // Mock the use24HourFormat flow + every { mockAppPreferenceRepository.use24HourFormat } returns MutableStateFlow(false) + + // Mock the locationFlow + every { mockLocationRepository.locationFlow } returns MutableStateFlow(testLocation) + + viewModel = TransitScreenViewModel( + context = mockContext, + locationRepository = mockLocationRepository, + transitousService = mockTransitousService, + appPreferenceRepository = mockAppPreferenceRepository + ) + } + + @Test + fun `initial state should be correct`() { + assertNull(viewModel.stop.value) + assertNull(viewModel.reverseGeocodedStop.value) + assertTrue(viewModel.departures.value.isEmpty()) + assertFalse(viewModel.didLoadingFail.value) + assertFalse(viewModel.isLoading.value) + assertFalse(viewModel.isRefreshingDepartures.value) + } + + @Test + fun `use24HourFormat should reflect repository state`() = runTest { + val expectedFlow = MutableStateFlow(true) + every { mockAppPreferenceRepository.use24HourFormat } returns expectedFlow + + viewModel = TransitScreenViewModel( + context = mockContext, + locationRepository = mockLocationRepository, + transitousService = mockTransitousService, + appPreferenceRepository = mockAppPreferenceRepository + ) + + assertEquals(true, viewModel.use24HourFormat.first()) + } + + @Test + fun `refreshData with location should reverse geocode and fetch departures`() = runTest { + // Mock reverse geocoding + coEvery { + mockTransitousService.reverseGeocode( + name = null, latitude = testLatLng.latitude, longitude = testLatLng.longitude, type = "STOP" + ) + } returns flowOf(listOf(testTransitStop)) + + // Mock stop times + coEvery { + mockTransitousService.getStopTimes( + testTransitStop.id, n = 200, radius = 1000 + ) + } returns flowOf(testStopTimesResponse) + + viewModel.refreshData() + advanceUntilIdle() + + coVerify { + mockTransitousService.reverseGeocode( + name = null, latitude = testLatLng.latitude, longitude = testLatLng.longitude, type = "STOP" + ) + } + coVerify { + mockTransitousService.getStopTimes( + testTransitStop.id, n = 200, radius = 1000 + ) + } + assertEquals(testTransitStop.id, viewModel.stop.value) + assertEquals(testTransitStop, viewModel.reverseGeocodedStop.value) + assertEquals(listOf(testStopTime), viewModel.departures.value) + assertFalse(viewModel.didLoadingFail.value) + assertFalse(viewModel.isLoading.value) + assertFalse(viewModel.isRefreshingDepartures.value) + } + + + @Test + fun `reverseGeocodeStop with exception should handle error gracefully`() = runTest { + coEvery { + mockTransitousService.reverseGeocode( + name = null, latitude = testLatLng.latitude, longitude = testLatLng.longitude, type = "STOP" + ) + } throws RuntimeException("Network error") + + viewModel.refreshData() + advanceUntilIdle() + + coVerify { + mockTransitousService.reverseGeocode( + name = null, latitude = testLatLng.latitude, longitude = testLatLng.longitude, type = "STOP" + ) + } + coVerify(exactly = 0) { mockTransitousService.getStopTimes(any(), any(), any()) } + assertNull(viewModel.stop.value) + assertNull(viewModel.reverseGeocodedStop.value) + assertTrue(viewModel.departures.value.isEmpty()) + assertFalse(viewModel.didLoadingFail.value) + assertFalse(viewModel.isLoading.value) + assertFalse(viewModel.isRefreshingDepartures.value) + } + + @Test + fun `fetchDepartures with exception should set didLoadingFail to true`() = runTest { + // Mock reverse geocoding to succeed + coEvery { + mockTransitousService.reverseGeocode( + name = null, latitude = testLatLng.latitude, longitude = testLatLng.longitude, type = "STOP" + ) + } returns flowOf(listOf(testTransitStop)) + + // Mock stop times to throw exception + coEvery { + mockTransitousService.getStopTimes( + testTransitStop.id, n = 200, radius = 1000 + ) + } throws RuntimeException("Network error") + + viewModel.refreshData() + advanceUntilIdle() + + coVerify { + mockTransitousService.reverseGeocode( + name = null, latitude = testLatLng.latitude, longitude = testLatLng.longitude, type = "STOP" + ) + } + coVerify { + mockTransitousService.getStopTimes( + testTransitStop.id, n = 200, radius = 1000 + ) + } + assertEquals(testTransitStop.id, viewModel.stop.value) + assertEquals(testTransitStop, viewModel.reverseGeocodedStop.value) + assertTrue(viewModel.departures.value.isEmpty()) + assertTrue(viewModel.didLoadingFail.value) + assertFalse(viewModel.isLoading.value) + assertFalse(viewModel.isRefreshingDepartures.value) + } + + @Test + fun `aggregateStopTimes should filter and sort stop times by proximity`() = runTest { + // Create multiple stop times at different locations + val farStopPlace = testStopPlace.copy(lat = 38.0, lon = -123.0) + val farStopTime = testStopTime.copy(place = farStopPlace) + + // Create stop times with same route but different stops + val sameRouteStopPlace1 = testStopPlace.copy(stopId = "stop-1") + val sameRouteStopPlace2 = testStopPlace.copy(stopId = "stop-2") + val sameRouteStopTime1 = testStopTime.copy(place = sameRouteStopPlace1) + val sameRouteStopTime2 = testStopTime.copy(place = sameRouteStopPlace2) + + val allStopTimes = listOf(farStopTime, sameRouteStopTime1, sameRouteStopTime2) + + // Mock reverse geocoding + coEvery { + mockTransitousService.reverseGeocode( + name = null, latitude = testLatLng.latitude, longitude = testLatLng.longitude, type = "STOP" + ) + } returns flowOf(listOf(testTransitStop)) + + // Mock stop times + coEvery { + mockTransitousService.getStopTimes( + testTransitStop.id, n = 200, radius = 1000 + ) + } returns flowOf(StopTimesResponse(stopTimes = allStopTimes, place = testStopPlace)) + + viewModel.refreshData() + advanceUntilIdle() + + // Should only include the closest stop for each route/headsign pair + val result = viewModel.departures.value + assertEquals(1, result.size) + assertEquals(sameRouteStopTime1.routeShortName, result[0].routeShortName) + assertEquals(sameRouteStopTime1.headsign, result[0].headsign) + } + + @Test + fun `aggregateStopTimes with null lastLocation should return empty list`() = runTest { + // Create a new ViewModel with null location + every { mockLocationRepository.locationFlow } returns MutableStateFlow(null) + + viewModel = TransitScreenViewModel( + context = mockContext, + locationRepository = mockLocationRepository, + transitousService = mockTransitousService, + appPreferenceRepository = mockAppPreferenceRepository + ) + + // Mock reverse geocoding to succeed + coEvery { + mockTransitousService.reverseGeocode( + name = null, latitude = testLatLng.latitude, longitude = testLatLng.longitude, type = "STOP" + ) + } returns flowOf(listOf(testTransitStop)) + + // Mock stop times + coEvery { + mockTransitousService.getStopTimes( + testTransitStop.id, n = 200, radius = 1000 + ) + } returns flowOf(testStopTimesResponse) + + // Manually set a location after creation + viewModel.refreshData() + advanceUntilIdle() + + // The aggregateStopTimes should handle null lastLocation gracefully + assertNotNull(viewModel.departures.value) + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/place/TransitStopCardViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/place/TransitStopCardViewModelTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..7373b668c22092064f178455c34e13b806a4329a --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/place/TransitStopCardViewModelTest.kt @@ -0,0 +1,320 @@ +/* + * 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 . + */ + +package earth.maps.cardinal.ui.place + +import earth.maps.cardinal.MainCoroutineRule +import earth.maps.cardinal.data.AppPreferenceRepository +import earth.maps.cardinal.data.LatLng +import earth.maps.cardinal.data.Place +import earth.maps.cardinal.data.room.SavedPlace +import earth.maps.cardinal.data.room.SavedPlaceDao +import earth.maps.cardinal.transit.StopPlace +import earth.maps.cardinal.transit.StopTime +import earth.maps.cardinal.transit.StopTimesResponse +import earth.maps.cardinal.transit.TransitousService +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.first +import kotlinx.coroutines.flow.flowOf +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 TransitStopCardViewModelTest { + + @ExperimentalCoroutinesApi + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + private lateinit var viewModel: TransitStopCardViewModel + private lateinit var mockPlaceDao: SavedPlaceDao + private lateinit var mockTransitousService: TransitousService + private lateinit var mockAppPreferenceRepository: AppPreferenceRepository + + private val testPlace = Place( + id = "test-place-id", + name = "Test Transit Stop", + description = "Test Description", + latLng = LatLng(37.7749, -122.4194), + isTransitStop = true, + transitStopId = "test-stop-id" + ) + + private val testSavedPlace = SavedPlace( + id = "test-saved-place-id", + placeId = null, + name = "Test Transit Stop", + type = "transit_stop", + icon = "transit", + latitude = 37.7749, + longitude = -122.4194, + isTransitStop = true, + transitStopId = "test-stop-id", + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + private val testStopPlace = StopPlace( + name = "Test Stop", + stopId = "test-stop-id", + lat = 37.7749, + lon = -122.4194, + level = 0.0, + tz = "America/Los_Angeles", + vertexType = "TRANSIT" + ) + + private val testStopTime = StopTime( + place = testStopPlace, + mode = "BUS", + realTime = true, + headsign = "Downtown", + agencyId = "agency-1", + agencyName = "Test Agency", + agencyUrl = "https://example.com", + routeColor = "FF0000", + tripId = "trip-1", + routeType = 3, + routeShortName = "42", + routeLongName = "Test Route", + tripShortName = null, + displayName = "42 - Downtown", + pickupDropoffType = "NORMAL", + cancelled = false, + tripCancelled = false, + source = "gtfs" + ) + + private val testStopTimesResponse = StopTimesResponse( + stopTimes = listOf(testStopTime), + place = testStopPlace + ) + + @Before + fun setup() { + mockPlaceDao = mockk() + mockTransitousService = mockk() + mockAppPreferenceRepository = mockk() + + // Mock the use24HourFormat flow + every { mockAppPreferenceRepository.use24HourFormat } returns MutableStateFlow(false) + + viewModel = TransitStopCardViewModel( + placeDao = mockPlaceDao, + transitousService = mockTransitousService, + appPreferenceRepository = mockAppPreferenceRepository + ) + } + + @Test + fun `initial state should be correct`() { + assertFalse(viewModel.isPlaceSaved.value) + assertNull(viewModel.stop.value) + assertTrue(viewModel.departures.value.isEmpty()) + assertFalse(viewModel.didLoadingFail.value) + assertFalse(viewModel.isLoading.value) + assertFalse(viewModel.isRefreshingDepartures.value) + } + + @Test + fun `setStop should update stop value`() { + viewModel.setStop(testPlace) + + assertEquals(testPlace, viewModel.stop.value) + } + + @Test + fun `checkIfPlaceIsSaved with existing place should update isPlaceSaved to true`() = runTest { + coEvery { mockPlaceDao.getPlace(testPlace.id!!) } returns testSavedPlace + + viewModel.checkIfPlaceIsSaved(testPlace) + advanceUntilIdle() + + assertTrue(viewModel.isPlaceSaved.value) + coVerify { mockPlaceDao.getPlace(testPlace.id!!) } + } + + @Test + fun `checkIfPlaceIsSaved with non-existing place should update isPlaceSaved to false`() = runTest { + coEvery { mockPlaceDao.getPlace(testPlace.id!!) } returns null + + viewModel.checkIfPlaceIsSaved(testPlace) + advanceUntilIdle() + + assertFalse(viewModel.isPlaceSaved.value) + coVerify { mockPlaceDao.getPlace(testPlace.id!!) } + } + + @Test + fun `checkIfPlaceIsSaved with null place ID should not call repository`() = runTest { + val placeWithoutId = testPlace.copy(id = null) + + viewModel.checkIfPlaceIsSaved(placeWithoutId) + advanceUntilIdle() + + coVerify(exactly = 0) { mockPlaceDao.getPlace(any()) } + assertFalse(viewModel.isPlaceSaved.value) + } + + @Test + fun `initializeDepartures with valid stop should check if place is saved and fetch departures`() = runTest { + // Mock the getStopTimes flow + coEvery { + mockTransitousService.getStopTimes(testPlace.transitStopId!!) + } returns flowOf(testStopTimesResponse) + + // Mock the placeDao.getPlace + coEvery { mockPlaceDao.getPlace(testPlace.id!!) } returns testSavedPlace + + viewModel.setStop(testPlace) + viewModel.initializeDepartures() + advanceUntilIdle() + + coVerify { mockPlaceDao.getPlace(testPlace.id!!) } + coVerify { mockTransitousService.getStopTimes(testPlace.transitStopId!!) } + assertTrue(viewModel.isPlaceSaved.value) + assertEquals(listOf(testStopTime), viewModel.departures.value) + assertFalse(viewModel.didLoadingFail.value) + assertFalse(viewModel.isLoading.value) + assertFalse(viewModel.isRefreshingDepartures.value) + } + + @Test + fun `initializeDepartures with null stop should log error and not fetch departures`() = runTest { + viewModel.initializeDepartures() + advanceUntilIdle() + + coVerify(exactly = 0) { mockPlaceDao.getPlace(any()) } + coVerify(exactly = 0) { mockTransitousService.getStopTimes(any()) } + assertTrue(viewModel.departures.value.isEmpty()) + } + + @Test + fun `fetchDepartures with valid transit stop ID should update departures`() = runTest { + coEvery { + mockTransitousService.getStopTimes(testPlace.transitStopId!!) + } returns flowOf(testStopTimesResponse) + // Mock the placeDao.getPlace call + coEvery { mockPlaceDao.getPlace(testPlace.id!!) } returns null + + viewModel.setStop(testPlace) + viewModel.initializeDepartures() + advanceUntilIdle() + + assertEquals(listOf(testStopTime), viewModel.departures.value) + assertFalse(viewModel.didLoadingFail.value) + assertFalse(viewModel.isLoading.value) + assertFalse(viewModel.isRefreshingDepartures.value) + } + + @Test + fun `fetchDepartures with empty transit stop ID should not update departures`() = runTest { + val placeWithoutTransitId = testPlace.copy(transitStopId = "") + // Mock the placeDao.getPlace call + coEvery { mockPlaceDao.getPlace(placeWithoutTransitId.id!!) } returns null + + viewModel.setStop(placeWithoutTransitId) + viewModel.initializeDepartures() + advanceUntilIdle() + + assertTrue(viewModel.departures.value.isEmpty()) + assertFalse(viewModel.didLoadingFail.value) + assertFalse(viewModel.isLoading.value) + assertFalse(viewModel.isRefreshingDepartures.value) + } + + @Test + fun `fetchDepartures with exception should set didLoadingFail to true`() = runTest { + coEvery { + mockTransitousService.getStopTimes(testPlace.transitStopId!!) + } throws RuntimeException("Network error") + // Mock the placeDao.getPlace call + coEvery { mockPlaceDao.getPlace(testPlace.id!!) } returns null + + viewModel.setStop(testPlace) + viewModel.initializeDepartures() + advanceUntilIdle() + + assertTrue(viewModel.departures.value.isEmpty()) + assertTrue(viewModel.didLoadingFail.value) + assertFalse(viewModel.isLoading.value) + assertFalse(viewModel.isRefreshingDepartures.value) + } + + @Test + fun `refreshDepartures should set isRefreshingDepartures during refresh`() = runTest { + // Mock the placeDao.getPlace call + coEvery { mockPlaceDao.getPlace(testPlace.id!!) } returns null + + // Create a flow that emits once and completes + coEvery { + mockTransitousService.getStopTimes(testPlace.transitStopId!!) + } returns flowOf(testStopTimesResponse) + + viewModel.setStop(testPlace) + + // Start refresh + viewModel.refreshDepartures() + + // Complete refresh + advanceUntilIdle() + assertFalse(viewModel.isRefreshingDepartures.value) + + coVerify { mockTransitousService.getStopTimes(testPlace.transitStopId!!) } + } + + @Test + fun `refreshDepartures with null stop should not call service`() = runTest { + viewModel.refreshDepartures() + advanceUntilIdle() + + coVerify(exactly = 0) { mockTransitousService.getStopTimes(any()) } + assertFalse(viewModel.isRefreshingDepartures.value) + } + + @Test + fun `use24HourFormat should reflect repository state`() = runTest { + val expectedFlow = MutableStateFlow(true) + every { mockAppPreferenceRepository.use24HourFormat } returns expectedFlow + + viewModel = TransitStopCardViewModel( + placeDao = mockPlaceDao, + transitousService = mockTransitousService, + appPreferenceRepository = mockAppPreferenceRepository + ) + + assertEquals(true, viewModel.use24HourFormat.first()) + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/saved/SavedPlacesViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/saved/SavedPlacesViewModelTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..0f842f0bfb8d3a92170dda0e3bc80a09ad133ce8 --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/saved/SavedPlacesViewModelTest.kt @@ -0,0 +1,122 @@ +/* + * 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 . + */ + +package earth.maps.cardinal.ui.saved + +import earth.maps.cardinal.MainCoroutineRule +import earth.maps.cardinal.data.LatLng +import earth.maps.cardinal.data.Place +import earth.maps.cardinal.data.room.SavedPlace +import earth.maps.cardinal.data.room.SavedPlaceDao +import earth.maps.cardinal.data.room.SavedPlaceRepository +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.runTest +import org.junit.Assert.assertEquals +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 SavedPlacesViewModelTest { + + @ExperimentalCoroutinesApi + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + private lateinit var viewModel: SavedPlacesViewModel + private lateinit var mockSavedPlaceDao: SavedPlaceDao + private lateinit var mockSavedPlaceRepository: SavedPlaceRepository + + 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 testPlace = Place( + id = "test-place-id", + name = "Test Place", + latLng = LatLng(37.7749, -122.4194) + ) + + @Before + fun setup() { + mockSavedPlaceDao = mockk() + mockSavedPlaceRepository = mockk() + + // Mock default flows + every { mockSavedPlaceDao.getAllPlacesAsFlow() } returns emptyFlow() + every { mockSavedPlaceRepository.toPlace(any()) } returns testPlace + + viewModel = SavedPlacesViewModel( + savedPlaceDao = mockSavedPlaceDao, + savedPlaceRepository = mockSavedPlaceRepository + ) + } + + @Test + fun `observeAllPlaces should return flow from DAO`() = runTest { + val expectedPlaces = listOf(testSavedPlace) + every { mockSavedPlaceDao.getAllPlacesAsFlow() } returns MutableStateFlow(expectedPlaces) + + val result = viewModel.observeAllPlaces() + val collectedResult = result.take(1).toList() + + assertEquals(1, collectedResult.size) + assertEquals(expectedPlaces, collectedResult[0]) + } + + @Test + fun `convertToPlace should call repository toPlace`() { + every { mockSavedPlaceRepository.toPlace(testSavedPlace) } returns testPlace + + val result = viewModel.convertToPlace(testSavedPlace) + + assertEquals(testPlace, result) + } + + @Test + fun `convertToPlace should handle different saved places`() { + val anotherSavedPlace = testSavedPlace.copy(id = "another-id", name = "Another Place") + val anotherPlace = testPlace.copy(id = "another-id", name = "Another Place") + + every { mockSavedPlaceRepository.toPlace(anotherSavedPlace) } returns anotherPlace + + val result = viewModel.convertToPlace(anotherSavedPlace) + + assertEquals(anotherPlace, result) + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/settings/ProfileEditorViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/settings/ProfileEditorViewModelTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..72b08e62089664c9daad88f85204aca2fd94d2b4 --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/settings/ProfileEditorViewModelTest.kt @@ -0,0 +1,284 @@ +package earth.maps.cardinal.ui.settings + +import earth.maps.cardinal.data.RoutingMode +import earth.maps.cardinal.data.room.RoutingProfile +import earth.maps.cardinal.data.room.RoutingProfileRepository +import earth.maps.cardinal.routing.AutoRoutingOptions +import earth.maps.cardinal.routing.CyclingRoutingOptions +import earth.maps.cardinal.routing.PedestrianRoutingOptions +import earth.maps.cardinal.routing.RoutingOptions +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.first +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.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileEditorViewModelTest { + + private lateinit var viewModel: ProfileEditorViewModel + private lateinit var mockRepository: RoutingProfileRepository + + @Before + fun setup() { + mockRepository = mockk(relaxed = true) + viewModel = ProfileEditorViewModel(repository = mockRepository) + } + + @Test + fun `loadProfile with null should initialize as new profile`() = runTest { + viewModel.loadProfile(null) + + assertEquals("", viewModel.profileName.first()) + assertEquals(RoutingMode.AUTO, viewModel.selectedMode.first()) + assertTrue(viewModel.routingOptions.first() is AutoRoutingOptions) + assertTrue(viewModel.isNewProfile.first()) + assertFalse(viewModel.hasUnsavedChanges.first()) + } + + @Test + fun `loadProfile with valid ID should load existing profile`() = runTest { + val profileId = "test-profile-id" + val testProfile = RoutingProfile( + id = profileId, + name = "Test Profile", + routingMode = "bicycle", + optionsJson = "{}", + isDefault = false + ) + val testOptions = CyclingRoutingOptions() + + coEvery { mockRepository.getProfileById(profileId) } returns Result.success(testProfile) + every { mockRepository.deserializeOptions("bicycle", "{}") } returns testOptions + + viewModel.loadProfile(profileId) + + assertEquals("Test Profile", viewModel.profileName.first()) + assertEquals(RoutingMode.BICYCLE, viewModel.selectedMode.first()) + assertEquals(testOptions, viewModel.routingOptions.first()) + assertFalse(viewModel.isNewProfile.first()) + assertFalse(viewModel.hasUnsavedChanges.first()) + } + + @Test + fun `loadProfile with non-existent ID should treat as new profile`() = runTest { + val profileId = "non-existent-id" + coEvery { mockRepository.getProfileById(profileId) } returns Result.success(null) + + viewModel.loadProfile(profileId) + + assertEquals("", viewModel.profileName.first()) + assertEquals(RoutingMode.AUTO, viewModel.selectedMode.first()) + assertTrue(viewModel.routingOptions.first() is AutoRoutingOptions) + assertTrue(viewModel.isNewProfile.first()) + assertFalse(viewModel.hasUnsavedChanges.first()) + } + + @Test + fun `loadProfile with error should set error message`() = runTest { + val profileId = "test-profile-id" + val errorMessage = "Failed to load" + coEvery { mockRepository.getProfileById(profileId) } returns Result.failure(Exception(errorMessage)) + + viewModel.loadProfile(profileId) + + assertEquals("Failed to load profile: $errorMessage", viewModel.error.first()) + } + + @Test + fun `updateProfileName should update name and mark as unsaved`() = runTest { + viewModel.loadProfile(null) // Start with new profile + val newName = "New Profile Name" + + viewModel.updateProfileName(newName) + + assertEquals(newName, viewModel.profileName.first()) + assertTrue(viewModel.hasUnsavedChanges.first()) + } + + @Test + fun `updateRoutingMode should update mode and options`() = runTest { + viewModel.loadProfile(null) // Start with new profile + + viewModel.updateRoutingMode(RoutingMode.BICYCLE) + + assertEquals(RoutingMode.BICYCLE, viewModel.selectedMode.first()) + assertTrue(viewModel.routingOptions.first() is CyclingRoutingOptions) + assertTrue(viewModel.hasUnsavedChanges.first()) + } + + @Test + fun `updateRoutingMode with same mode should not change anything`() = runTest { + viewModel.loadProfile(null) // Start with AUTO mode + val initialOptions = viewModel.routingOptions.first() + + viewModel.updateRoutingMode(RoutingMode.AUTO) + + assertEquals(RoutingMode.AUTO, viewModel.selectedMode.first()) + assertEquals(initialOptions, viewModel.routingOptions.first()) + assertFalse(viewModel.hasUnsavedChanges.first()) + } + + @Test + fun `updateRoutingOptions should update options and mark as unsaved`() = runTest { + viewModel.loadProfile(null) // Start with new profile + val newOptions = CyclingRoutingOptions() + + viewModel.updateRoutingOptions(newOptions) + + assertEquals(newOptions, viewModel.routingOptions.first()) + assertTrue(viewModel.hasUnsavedChanges.first()) + } + + @Test + fun `saveProfile with new profile should call repository createProfile`() = runTest { + viewModel.loadProfile(null) + viewModel.updateProfileName("Test Profile") + + val profileId = "new-profile-id" + coEvery { + mockRepository.createProfile("Test Profile", RoutingMode.AUTO, any()) + } returns Result.success(profileId) + + var onSuccessCalled = false + viewModel.saveProfile { onSuccessCalled = true } + + coVerify { + mockRepository.createProfile("Test Profile", RoutingMode.AUTO, any()) + } + assertTrue(onSuccessCalled) + assertNull(viewModel.error.first()) + } + + @Test + fun `saveProfile with existing profile should call repository updateProfile`() = runTest { + val profileId = "existing-profile-id" + val testProfile = RoutingProfile( + id = profileId, + name = "Test Profile", + routingMode = "auto", + optionsJson = "{}", + isDefault = false + ) + val testOptions = AutoRoutingOptions() + + coEvery { mockRepository.getProfileById(profileId) } returns Result.success(testProfile) + every { mockRepository.deserializeOptions("auto", "{}") } returns testOptions + coEvery { mockRepository.updateProfile(profileId, "Updated Profile", testOptions) } returns Result.success(Unit) + + viewModel.loadProfile(profileId) + viewModel.updateProfileName("Updated Profile") + + var onSuccessCalled = false + viewModel.saveProfile { onSuccessCalled = true } + + coVerify { mockRepository.updateProfile(profileId, "Updated Profile", testOptions) } + assertTrue(onSuccessCalled) + assertNull(viewModel.error.first()) + } + + @Test + fun `saveProfile with empty name should set error`() = runTest { + viewModel.loadProfile(null) + viewModel.updateProfileName("") + + var onSuccessCalled = false + viewModel.saveProfile { onSuccessCalled = true } + + assertEquals("Profile name cannot be empty", viewModel.error.first()) + assertFalse(onSuccessCalled) + } + + @Test + fun `saveProfile with blank name should set error`() = runTest { + viewModel.loadProfile(null) + viewModel.updateProfileName(" ") + + var onSuccessCalled = false + viewModel.saveProfile { onSuccessCalled = true } + + assertEquals("Profile name cannot be empty", viewModel.error.first()) + assertFalse(onSuccessCalled) + } + + @Test + fun `saveProfile with repository error should set error message`() = runTest { + viewModel.loadProfile(null) + viewModel.updateProfileName("Test Profile") + + val errorMessage = "Save failed" + coEvery { + mockRepository.createProfile("Test Profile", RoutingMode.AUTO, any()) + } returns Result.failure(Exception(errorMessage)) + + var onSuccessCalled = false + viewModel.saveProfile { onSuccessCalled = true } + + assertEquals("Failed to save profile: $errorMessage", viewModel.error.first()) + assertFalse(onSuccessCalled) + } + + @Test + fun `clearError should clear error message`() = runTest { + viewModel.loadProfile(null) + viewModel.updateProfileName("") // Trigger validation error + viewModel.saveProfile {} // This will set an error + + assertNotNull(viewModel.error.first()) + + viewModel.clearError() + + assertNull(viewModel.error.first()) + } + + @Test + fun `hasUnsavedChanges should track changes correctly`() = runTest { + viewModel.loadProfile(null) + + // Initially no changes + assertFalse(viewModel.hasUnsavedChanges.first()) + + // Change name + viewModel.updateProfileName("New Name") + assertTrue(viewModel.hasUnsavedChanges.first()) + + // Reset and change mode + viewModel.loadProfile(null) + viewModel.updateRoutingMode(RoutingMode.BICYCLE) + assertTrue(viewModel.hasUnsavedChanges.first()) + + // Reset and change options + viewModel.loadProfile(null) + val newOptions = CyclingRoutingOptions() + viewModel.updateRoutingOptions(newOptions) + assertTrue(viewModel.hasUnsavedChanges.first()) + } + + @Test + fun `different routing modes should create correct default options`() = runTest { + viewModel.loadProfile(null) + + // Test each routing mode + viewModel.updateRoutingMode(RoutingMode.PEDESTRIAN) + assertTrue(viewModel.routingOptions.first() is PedestrianRoutingOptions) + + viewModel.updateRoutingMode(RoutingMode.BICYCLE) + assertTrue(viewModel.routingOptions.first() is CyclingRoutingOptions) + + viewModel.updateRoutingMode(RoutingMode.AUTO) + assertTrue(viewModel.routingOptions.first() is AutoRoutingOptions) + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/settings/RoutingProfilesViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/settings/RoutingProfilesViewModelTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..1a5148e8666ea9a40d427bc02b8490f835f734ca --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/settings/RoutingProfilesViewModelTest.kt @@ -0,0 +1,177 @@ +package earth.maps.cardinal.ui.settings + +import earth.maps.cardinal.data.room.RoutingProfile +import earth.maps.cardinal.data.room.RoutingProfileRepository +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.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class RoutingProfilesViewModelTest { + + private lateinit var viewModel: RoutingProfilesViewModel + private lateinit var mockRepository: RoutingProfileRepository + + @Before + fun setup() { + mockRepository = mockk(relaxed = true) + + // Setup default StateFlow behaviors + every { mockRepository.allProfiles } returns MutableStateFlow(emptyList()) + + viewModel = RoutingProfilesViewModel(repository = mockRepository) + } + + @Test + fun `allProfiles should reflect repository state`() = runTest { + val testProfiles = listOf( + RoutingProfile( + id = "1", + name = "Test Profile 1", + routingMode = "auto", + optionsJson = "{}", + isDefault = true + ), + RoutingProfile( + id = "2", + name = "Test Profile 2", + routingMode = "bicycle", + optionsJson = "{}", + isDefault = false + ) + ) + val expectedFlow = MutableStateFlow(testProfiles) + every { mockRepository.allProfiles } returns expectedFlow + + // Re-initialize viewModel to use the new mock + viewModel = RoutingProfilesViewModel(repository = mockRepository) + + assertEquals(testProfiles, viewModel.allProfiles.first()) + } + + @Test + fun `deleteProfile should call repository deleteProfile and handle success`() = runTest { + val profileId = "test-profile-id" + coEvery { mockRepository.deleteProfile(profileId) } returns Result.success(Unit) + + viewModel.deleteProfile(profileId) + + coVerify { mockRepository.deleteProfile(profileId) } + // Verify no error is set on success + assertNull(viewModel.error.first()) + } + + @Test + fun `deleteProfile should handle failure and set error message`() = runTest { + val profileId = "test-profile-id" + val errorMessage = "Failed to delete" + coEvery { mockRepository.deleteProfile(profileId) } returns Result.failure(Exception(errorMessage)) + + viewModel.deleteProfile(profileId) + + coVerify { mockRepository.deleteProfile(profileId) } + // Verify error message is set + assertEquals("Failed to delete profile: $errorMessage", viewModel.error.first()) + } + + @Test + fun `setDefaultProfile should call repository setDefaultProfile and handle success`() = runTest { + val profileId = "test-profile-id" + coEvery { mockRepository.setDefaultProfile(profileId) } returns Result.success(Unit) + + viewModel.setDefaultProfile(profileId) + + coVerify { mockRepository.setDefaultProfile(profileId) } + // Verify no error is set on success + assertNull(viewModel.error.first()) + } + + @Test + fun `setDefaultProfile should handle failure and set error message`() = runTest { + val profileId = "test-profile-id" + val errorMessage = "Failed to set default" + coEvery { mockRepository.setDefaultProfile(profileId) } returns Result.failure(Exception(errorMessage)) + + viewModel.setDefaultProfile(profileId) + + coVerify { mockRepository.setDefaultProfile(profileId) } + // Verify error message is set + assertEquals("Failed to set default profile: $errorMessage", viewModel.error.first()) + } + + @Test + fun `error should be null initially`() = runTest { + assertNull(viewModel.error.first()) + } + + @Test + fun `error should be cleared after successful operation`() = runTest { + val profileId = "test-profile-id" + + // First, simulate an error + coEvery { mockRepository.deleteProfile(profileId) } returns Result.failure(Exception("Error")) + viewModel.deleteProfile(profileId) + assertNotNull(viewModel.error.first()) + + // Then simulate success + coEvery { mockRepository.deleteProfile(profileId) } returns Result.success(Unit) + viewModel.deleteProfile(profileId) + + // Error should be cleared + assertNull(viewModel.error.first()) + } + + @Test + fun `deleteProfile with different profile IDs should work independently`() = runTest { + val profileId1 = "profile-1" + val profileId2 = "profile-2" + + coEvery { mockRepository.deleteProfile(profileId1) } returns Result.success(Unit) + coEvery { mockRepository.deleteProfile(profileId2) } returns Result.failure(Exception("Error")) + + // Delete first profile successfully + viewModel.deleteProfile(profileId1) + assertNull(viewModel.error.first()) + + // Delete second profile with error + viewModel.deleteProfile(profileId2) + assertEquals("Failed to delete profile: Error", viewModel.error.first()) + + coVerify { mockRepository.deleteProfile(profileId1) } + coVerify { mockRepository.deleteProfile(profileId2) } + } + + @Test + fun `setDefaultProfile with different profile IDs should work independently`() = runTest { + val profileId1 = "profile-1" + val profileId2 = "profile-2" + + coEvery { mockRepository.setDefaultProfile(profileId1) } returns Result.success(Unit) + coEvery { mockRepository.setDefaultProfile(profileId2) } returns Result.failure(Exception("Error")) + + // Set first profile as default successfully + viewModel.setDefaultProfile(profileId1) + assertNull(viewModel.error.first()) + + // Set second profile as default with error + viewModel.setDefaultProfile(profileId2) + assertEquals("Failed to set default profile: Error", viewModel.error.first()) + + coVerify { mockRepository.setDefaultProfile(profileId1) } + coVerify { mockRepository.setDefaultProfile(profileId2) } + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/settings/SettingsViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/settings/SettingsViewModelTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..8444652520e5d42ec3710c66f3312484a5d8040a --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/settings/SettingsViewModelTest.kt @@ -0,0 +1,341 @@ +package earth.maps.cardinal.ui.settings + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import androidx.core.net.toUri +import earth.maps.cardinal.data.ApiConfiguration +import earth.maps.cardinal.data.AppPreferenceRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowLooper + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + + private lateinit var context: Context + private lateinit var viewModel: SettingsViewModel + private lateinit var mockAppPreferenceRepository: AppPreferenceRepository + + @Before + fun setup() { + context = Robolectric.buildActivity(androidx.activity.ComponentActivity::class.java).get() + mockAppPreferenceRepository = mockk(relaxed = true) + + // Setup default StateFlow behaviors + every { mockAppPreferenceRepository.offlineMode } returns MutableStateFlow(false) + every { mockAppPreferenceRepository.allowTransitInOfflineMode } returns MutableStateFlow(false) + every { mockAppPreferenceRepository.contrastLevel } returns MutableStateFlow(0) + every { mockAppPreferenceRepository.animationSpeed } returns MutableStateFlow(0) + every { mockAppPreferenceRepository.peliasApiConfig } returns MutableStateFlow( + ApiConfiguration("https://api.example.com", "test-key") + ) + every { mockAppPreferenceRepository.valhallaApiConfig } returns MutableStateFlow( + ApiConfiguration("https://valhalla.example.com", "valhalla-key") + ) + every { mockAppPreferenceRepository.continuousLocationTracking } returns MutableStateFlow(false) + every { mockAppPreferenceRepository.showZoomFabs } returns MutableStateFlow(true) + every { mockAppPreferenceRepository.use24HourFormat } returns MutableStateFlow(false) + every { mockAppPreferenceRepository.distanceUnit } returns MutableStateFlow(0) + + viewModel = SettingsViewModel( + context = context, + appPreferenceRepository = mockAppPreferenceRepository + ) + } + + @Test + fun `offlineMode should reflect repository state`() = runTest { + val expectedFlow = MutableStateFlow(true) + every { mockAppPreferenceRepository.offlineMode } returns expectedFlow + + // Re-initialize viewModel to use the new mock + viewModel = SettingsViewModel( + context = context, + appPreferenceRepository = mockAppPreferenceRepository + ) + + assertEquals(true, viewModel.offlineMode.first()) + + expectedFlow.value = false + assertEquals(false, viewModel.offlineMode.first()) + } + + @Test + fun `setOfflineMode should call repository setOfflineMode`() = runTest { + viewModel.setOfflineMode(true) + verify { mockAppPreferenceRepository.setOfflineMode(true) } + + viewModel.setOfflineMode(false) + verify { mockAppPreferenceRepository.setOfflineMode(false) } + } + + @Test + fun `allowTransitInOfflineMode should reflect repository state`() = runTest { + val expectedFlow = MutableStateFlow(true) + every { mockAppPreferenceRepository.allowTransitInOfflineMode } returns expectedFlow + + // Re-initialize viewModel to use the new mock + viewModel = SettingsViewModel( + context = context, + appPreferenceRepository = mockAppPreferenceRepository + ) + + assertEquals(true, viewModel.allowTransitInOfflineMode.first()) + + expectedFlow.value = false + assertEquals(false, viewModel.allowTransitInOfflineMode.first()) + } + + @Test + fun `setAllowTransitInOfflineMode should call repository setAllowTransitInOfflineMode`() = runTest { + viewModel.setAllowTransitInOfflineMode(true) + verify { mockAppPreferenceRepository.setAllowTransitInOfflineMode(true) } + + viewModel.setAllowTransitInOfflineMode(false) + verify { mockAppPreferenceRepository.setAllowTransitInOfflineMode(false) } + } + + @Test + fun `contrastLevel should reflect repository state`() = runTest { + val expectedFlow = MutableStateFlow(2) + every { mockAppPreferenceRepository.contrastLevel } returns expectedFlow + + // Re-initialize viewModel to use the new mock + viewModel = SettingsViewModel( + context = context, + appPreferenceRepository = mockAppPreferenceRepository + ) + + assertEquals(2, viewModel.contrastLevel.first()) + + expectedFlow.value = 1 + assertEquals(1, viewModel.contrastLevel.first()) + } + + @Test + fun `setContrastLevel should call repository setContrastLevel`() = runTest { + viewModel.setContrastLevel(1) + verify { mockAppPreferenceRepository.setContrastLevel(1) } + + viewModel.setContrastLevel(2) + verify { mockAppPreferenceRepository.setContrastLevel(2) } + } + + @Test + fun `animationSpeed should reflect repository state`() = runTest { + val expectedFlow = MutableStateFlow(1) + every { mockAppPreferenceRepository.animationSpeed } returns expectedFlow + + // Re-initialize viewModel to use the new mock + viewModel = SettingsViewModel( + context = context, + appPreferenceRepository = mockAppPreferenceRepository + ) + + assertEquals(1, viewModel.animationSpeed.first()) + + expectedFlow.value = 2 + assertEquals(2, viewModel.animationSpeed.first()) + } + + @Test + fun `setAnimationSpeed should call repository setAnimationSpeed`() = runTest { + viewModel.setAnimationSpeed(1) + verify { mockAppPreferenceRepository.setAnimationSpeed(1) } + + viewModel.setAnimationSpeed(2) + verify { mockAppPreferenceRepository.setAnimationSpeed(2) } + } + + @Test + fun `peliasApiConfig should reflect repository state`() = runTest { + val expectedConfig = ApiConfiguration("https://new.pelias.com", "new-key") + val expectedFlow = MutableStateFlow(expectedConfig) + every { mockAppPreferenceRepository.peliasApiConfig } returns expectedFlow + + // Re-initialize viewModel to use the new mock + viewModel = SettingsViewModel( + context = context, + appPreferenceRepository = mockAppPreferenceRepository + ) + + assertEquals(expectedConfig, viewModel.peliasApiConfig.first()) + } + + @Test + fun `setPeliasBaseUrl should call repository setPeliasBaseUrl`() = runTest { + val baseUrl = "https://new.pelias.com" + viewModel.setPeliasBaseUrl(baseUrl) + verify { mockAppPreferenceRepository.setPeliasBaseUrl(baseUrl) } + } + + @Test + fun `setPeliasApiKey should call repository setPeliasApiKey`() = runTest { + val apiKey = "new-api-key" + viewModel.setPeliasApiKey(apiKey) + verify { mockAppPreferenceRepository.setPeliasApiKey(apiKey) } + + viewModel.setPeliasApiKey(null) + verify { mockAppPreferenceRepository.setPeliasApiKey(null) } + } + + @Test + fun `valhallaApiConfig should reflect repository state`() = runTest { + val expectedConfig = ApiConfiguration("https://new.valhalla.com", "new-valhalla-key") + val expectedFlow = MutableStateFlow(expectedConfig) + every { mockAppPreferenceRepository.valhallaApiConfig } returns expectedFlow + + // Re-initialize viewModel to use the new mock + viewModel = SettingsViewModel( + context = context, + appPreferenceRepository = mockAppPreferenceRepository + ) + + assertEquals(expectedConfig, viewModel.valhallaApiConfig.first()) + } + + @Test + fun `setValhallaBaseUrl should call repository setValhallaBaseUrl`() = runTest { + val baseUrl = "https://new.valhalla.com" + viewModel.setValhallaBaseUrl(baseUrl) + verify { mockAppPreferenceRepository.setValhallaBaseUrl(baseUrl) } + } + + @Test + fun `setValhallaApiKey should call repository setValhallaApiKey`() = runTest { + val apiKey = "new-valhalla-key" + viewModel.setValhallaApiKey(apiKey) + verify { mockAppPreferenceRepository.setValhallaApiKey(apiKey) } + + viewModel.setValhallaApiKey(null) + verify { mockAppPreferenceRepository.setValhallaApiKey(null) } + } + + @Test + fun `continuousLocationTracking should reflect repository state`() = runTest { + val expectedFlow = MutableStateFlow(true) + every { mockAppPreferenceRepository.continuousLocationTracking } returns expectedFlow + + // Re-initialize viewModel to use the new mock + viewModel = SettingsViewModel( + context = context, + appPreferenceRepository = mockAppPreferenceRepository + ) + + assertEquals(true, viewModel.continuousLocationTracking.first()) + + expectedFlow.value = false + assertEquals(false, viewModel.continuousLocationTracking.first()) + } + + @Test + fun `setContinuousLocationTrackingEnabled should call repository setContinuousLocationTracking`() = runTest { + viewModel.setContinuousLocationTrackingEnabled(true) + verify { mockAppPreferenceRepository.setContinuousLocationTracking(true) } + + viewModel.setContinuousLocationTrackingEnabled(false) + verify { mockAppPreferenceRepository.setContinuousLocationTracking(false) } + } + + @Test + fun `showZoomFabs should reflect repository state`() = runTest { + val expectedFlow = MutableStateFlow(false) + every { mockAppPreferenceRepository.showZoomFabs } returns expectedFlow + + // Re-initialize viewModel to use the new mock + viewModel = SettingsViewModel( + context = context, + appPreferenceRepository = mockAppPreferenceRepository + ) + + assertEquals(false, viewModel.showZoomFabs.first()) + + expectedFlow.value = true + assertEquals(true, viewModel.showZoomFabs.first()) + } + + @Test + fun `setShowZoomFabsEnabled should call repository setShowZoomFabs`() = runTest { + viewModel.setShowZoomFabsEnabled(true) + verify { mockAppPreferenceRepository.setShowZoomFabs(true) } + + viewModel.setShowZoomFabsEnabled(false) + verify { mockAppPreferenceRepository.setShowZoomFabs(false) } + } + + @Test + fun `use24HourFormat should reflect repository state`() = runTest { + val expectedFlow = MutableStateFlow(true) + every { mockAppPreferenceRepository.use24HourFormat } returns expectedFlow + + // Re-initialize viewModel to use the new mock + viewModel = SettingsViewModel( + context = context, + appPreferenceRepository = mockAppPreferenceRepository + ) + + assertEquals(true, viewModel.use24HourFormat.first()) + + expectedFlow.value = false + assertEquals(false, viewModel.use24HourFormat.first()) + } + + @Test + fun `setUse24HourFormat should call repository setUse24HourFormat`() = runTest { + viewModel.setUse24HourFormat(true) + verify { mockAppPreferenceRepository.setUse24HourFormat(true) } + + viewModel.setUse24HourFormat(false) + verify { mockAppPreferenceRepository.setUse24HourFormat(false) } + } + + @Test + fun `distanceUnit should reflect repository state`() = runTest { + val expectedFlow = MutableStateFlow(1) + every { mockAppPreferenceRepository.distanceUnit } returns expectedFlow + + // Re-initialize viewModel to use the new mock + viewModel = SettingsViewModel( + context = context, + appPreferenceRepository = mockAppPreferenceRepository + ) + + assertEquals(1, viewModel.distanceUnit.first()) + + expectedFlow.value = 2 + assertEquals(2, viewModel.distanceUnit.first()) + } + + @Test + fun `setDistanceUnit should call repository setDistanceUnit`() = runTest { + viewModel.setDistanceUnit(1) + verify { mockAppPreferenceRepository.setDistanceUnit(1) } + + viewModel.setDistanceUnit(2) + verify { mockAppPreferenceRepository.setDistanceUnit(2) } + } + + @Test + fun `getVersionName should execute without crashing`() { + // Test that the method executes without throwing an exception + // We can't easily test the exact output due to test environment limitations + viewModel.getVersionName() + // If we reach here, the method executed successfully + } +} \ No newline at end of file