From 6d953c1be5df6df142c2053cefc0b5d6e11908b7 Mon Sep 17 00:00:00 2001 From: Pranshul Date: Wed, 26 Aug 2026 16:54:26 +0530 Subject: [PATCH] Revert "Refactor: moved business logic to Use Cases and added unit test suite" --- app/build.gradle.kts | 4 +- .../repository/data/SourceDataRepository.kt | 2 + .../domain/usecase/DeleteLocationUseCase.kt | 19 -- .../domain/usecase/GetWeatherUseCase.kt | 82 ------ .../usecase/LoadWeatherBlocksUseCase.kt | 23 -- .../domain/usecase/README.md | 23 -- .../usecase/SaveWeatherBlocksUseCase.kt | 20 -- .../usecase/UpdateLocationSourceUseCase.kt | 55 ---- .../feature/daily/DailyScreenViewModel.kt | 17 +- .../feature/main/MainScreen.kt | 6 +- .../feature/shared/WeatherViewModel.kt | 153 +++++++---- .../usecase/DeleteLocationUseCaseTest.kt | 25 -- .../domain/usecase/GetWeatherUseCaseTest.kt | 242 ------------------ .../usecase/LoadWeatherBlocksUseCaseTest.kt | 46 ---- .../usecase/SaveWeatherBlocksUseCaseTest.kt | 43 ---- .../UpdateLocationSourceUseCaseTest.kt | 68 ----- .../feature/daily/DailyScreenViewModelTest.kt | 63 ----- .../feature/shared/WeatherViewModelTest.kt | 221 ---------------- gradle/libs.versions.toml | 8 +- 19 files changed, 124 insertions(+), 996 deletions(-) delete mode 100644 app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/DeleteLocationUseCase.kt delete mode 100644 app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/GetWeatherUseCase.kt delete mode 100644 app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/LoadWeatherBlocksUseCase.kt delete mode 100644 app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/README.md delete mode 100644 app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/SaveWeatherBlocksUseCase.kt delete mode 100644 app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/UpdateLocationSourceUseCase.kt delete mode 100644 app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/DeleteLocationUseCaseTest.kt delete mode 100644 app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/GetWeatherUseCaseTest.kt delete mode 100644 app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/LoadWeatherBlocksUseCaseTest.kt delete mode 100644 app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/SaveWeatherBlocksUseCaseTest.kt delete mode 100644 app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/UpdateLocationSourceUseCaseTest.kt delete mode 100644 app/src/test/java/com/pranshulgg/weather_master_app/feature/daily/DailyScreenViewModelTest.kt delete mode 100644 app/src/test/java/com/pranshulgg/weather_master_app/feature/shared/WeatherViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 77895602da..39717399ab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -174,6 +174,7 @@ dependencies { ksp(libs.hilt.android.compiler) ksp(libs.hilt.compiler) implementation(libs.reorderable) + implementation(libs.androidx.foundation.layout) implementation(libs.androidx.animation.core) implementation(libs.core.splashscreen) @@ -187,9 +188,6 @@ dependencies { implementation(libs.kotlinx.serialization.json) testImplementation(libs.junit) - testImplementation(libs.mockk) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.turbine) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/data/repository/data/SourceDataRepository.kt b/app/src/main/java/com/pranshulgg/weather_master_app/data/repository/data/SourceDataRepository.kt index 08c3e4b3ce..cf8ee46b0e 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/data/repository/data/SourceDataRepository.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/data/repository/data/SourceDataRepository.kt @@ -89,5 +89,7 @@ class SourceDataRepository @Inject constructor( launch { onWeather(weatherJob.await()) } + + } } \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/DeleteLocationUseCase.kt b/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/DeleteLocationUseCase.kt deleted file mode 100644 index 53071ba9c8..0000000000 --- a/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/DeleteLocationUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.pranshulgg.weather_master_app.domain.usecase - -/** - * Initial Clean Architecture Domain Layer integration implemented by https://github.com/gietabhi10 - */ - -import com.pranshulgg.weather_master_app.data.repository.LocationsRepository -import javax.inject.Inject - -/** - * Use case to delete a location and its associated data from the local database. - */ -class DeleteLocationUseCase @Inject constructor( - private val locationsRepo: LocationsRepository -) { - suspend operator fun invoke(id: String) { - locationsRepo.deleteLocation(id) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/GetWeatherUseCase.kt b/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/GetWeatherUseCase.kt deleted file mode 100644 index ff074bc48b..0000000000 --- a/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/GetWeatherUseCase.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.pranshulgg.weather_master_app.domain.usecase - -/** - * Initial Clean Architecture Domain Layer integration implemented by https://github.com/gietabhi10 - */ - -import com.pranshulgg.weather_master_app.core.model.domain.location.Location -import com.pranshulgg.weather_master_app.core.model.weather.WeatherResult -import com.pranshulgg.weather_master_app.core.model.weather.airquality.AirQualityResult -import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertResult -import com.pranshulgg.weather_master_app.data.repository.LocationsRepository -import com.pranshulgg.weather_master_app.data.repository.data.SourceDataRepository -import javax.inject.Inject - -/** - * Orchestrates the fetching of weather, alerts, and air quality data for a given location. - */ -class GetWeatherUseCase @Inject constructor( - private val locationsRepo: LocationsRepository, - private val sourceDataRepository: SourceDataRepository -) { - /** - * Executes the weather fetch process. - * - * This function should be called from a coroutine scope (e.g., [viewModelScope]). - * It handles internal suspension and parallel execution of data fetching. - * Cancellation of the calling coroutine will correctly cancel all internal - * asynchronous operations. - * - * All callbacks are invoked on the dispatcher used by the caller. - * - * @param location The location to fetch data for. - * @param isManualRefresh Whether this is a user-initiated refresh (e.g., pull-to-refresh). - * @param isForceRefresh Whether to bypass caches for weather data. - * @param isForceRefreshForAirQuality Whether to bypass caches for air quality data. - * @param isForceRefreshForAlerts Whether to bypass caches for alerts. - * @param onLocationUpdated Callback invoked immediately if the device location's coordinates are updated. - * @param onWeather Callback invoked when weather data is successfully fetched or fails. - * @param onAlerts Callback invoked when alerts are fetched. - * @param onAirQuality Callback invoked when air quality data is fetched. - */ - suspend operator fun invoke( - location: Location, - isManualRefresh: Boolean = false, - isForceRefresh: Boolean = false, - isForceRefreshForAirQuality: Boolean = false, - isForceRefreshForAlerts: Boolean = false, - onLocationUpdated: suspend (Location) -> Unit = {}, - onWeather: suspend (WeatherResult, Location) -> Unit, - onAlerts: suspend (AlertResult?) -> Unit, - onAirQuality: suspend (AirQualityResult?) -> Unit, - ) { - var effectiveLocation = location - var effectiveForceRefresh = isForceRefresh - var effectiveForceRefreshForAirQuality = isForceRefreshForAirQuality - var effectiveForceRefreshForAlerts = isForceRefreshForAlerts - - if (location.isDeviceLocation) { - val positionChanged = locationsRepo.updateDeviceLocationPosition() - if (positionChanged) { - effectiveLocation = locationsRepo.getLocationForId(location.id) - effectiveForceRefresh = true - effectiveForceRefreshForAirQuality = true - effectiveForceRefreshForAlerts = true - onLocationUpdated(effectiveLocation) - } - } - - sourceDataRepository.getData( - location = effectiveLocation, - isManualRefresh = isManualRefresh, - isForceRefresh = effectiveForceRefresh, - isForceRefreshForAirQuality = effectiveForceRefreshForAirQuality, - isForceRefreshForAlerts = effectiveForceRefreshForAlerts, - onWeather = { result -> - onWeather(result, effectiveLocation) - }, - onAlerts = onAlerts, - onAirQuality = onAirQuality - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/LoadWeatherBlocksUseCase.kt b/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/LoadWeatherBlocksUseCase.kt deleted file mode 100644 index ed02f2195c..0000000000 --- a/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/LoadWeatherBlocksUseCase.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.pranshulgg.weather_master_app.domain.usecase - -/** - * Initial Clean Architecture Domain Layer integration implemented by https://github.com/gietabhi10 - */ - -import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherBlock -import com.pranshulgg.weather_master_app.data.repository.WeatherBlocksRepository -import javax.inject.Inject - -/** - * Use case to load the configured weather blocks (e.g. Humidity, UV Index) for a screen. - * - * It handles fetching the saved order/visibility from the database and falling back - * to defaults if no custom configuration exists. - */ -class LoadWeatherBlocksUseCase @Inject constructor( - private val weatherBlocksRepository: WeatherBlocksRepository -) { - suspend operator fun invoke(isDaily: Boolean = false): List { - return weatherBlocksRepository.loadBlocks(isDaily) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/README.md b/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/README.md deleted file mode 100644 index 4fe80e471c..0000000000 --- a/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Domain Layer: Use Cases - -This package contains the business logic of the application, following **Clean Architecture** principles. - -## Responsibility Split - -### 1. Use Cases (This Package) -- **What they do**: Orchestrate complex operations that span multiple repositories or involve business-specific rules. -- **Example**: `GetWeatherUseCase` coordinates location updates with data fetching from weather, alerts, and air quality sources. -- **Rule**: They should be platform-independent and reusable across different ViewModels. - -### 2. ViewModels (`feature/**`) -- **What they do**: Manage UI state and handle user interactions. -- **Rule**: They should delegate business logic to Use Cases and focus purely on UI logic (loading states, error handling, mapping data to UI models). - -### 3. Repositories (`data/repository/**`) -- **What they do**: Provide a clean API for data access (Remote vs. Local). -- **Rule**: They handle data mapping and caching logic but should not contain orchestration logic that involves other feature areas. - -## Best Practices -- **Mocking**: Use Cases should be mocked in ViewModel tests to verify delegation. -- **Threading**: Use Cases are `suspend` functions and should be called from a coroutine scope (e.g., `viewModelScope`). -- **Cancellation**: All asynchronous operations within a Use Case should respect the cancellation of the calling coroutine. \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/SaveWeatherBlocksUseCase.kt b/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/SaveWeatherBlocksUseCase.kt deleted file mode 100644 index 89a3ee4862..0000000000 --- a/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/SaveWeatherBlocksUseCase.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.pranshulgg.weather_master_app.domain.usecase - -/** - * Initial Clean Architecture Domain Layer integration implemented by https://github.com/gietabhi10 - */ - -import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherBlock -import com.pranshulgg.weather_master_app.data.repository.WeatherBlocksRepository -import javax.inject.Inject - -/** - * Use case to save the order and visibility configuration of weather blocks to the database. - */ -class SaveWeatherBlocksUseCase @Inject constructor( - private val weatherBlocksRepository: WeatherBlocksRepository -) { - suspend operator fun invoke(items: List, isDaily: Boolean = false) { - weatherBlocksRepository.saveBlocks(items, isDaily) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/UpdateLocationSourceUseCase.kt b/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/UpdateLocationSourceUseCase.kt deleted file mode 100644 index 7280ba9ccc..0000000000 --- a/app/src/main/java/com/pranshulgg/weather_master_app/domain/usecase/UpdateLocationSourceUseCase.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.pranshulgg.weather_master_app.domain.usecase - -/** - * Initial Clean Architecture Domain Layer integration implemented by https://github.com/gietabhi10 - */ - -import com.pranshulgg.weather_master_app.core.model.domain.location.Location -import com.pranshulgg.weather_master_app.core.model.weather.openmeteo.OpenMeteoModel -import com.pranshulgg.weather_master_app.core.model.sources.Source -import com.pranshulgg.weather_master_app.data.repository.LocationsRepository -import com.pranshulgg.weather_master_app.data.repository.WeatherDataReconcilerRepository -import javax.inject.Inject - -/** - * Use case to update weather, air quality, and alert sources for a specific location. - * - * This use case updates the location configuration in the database and triggers - * data reconciliation to clean up any stale data associated with previous sources. - */ -class UpdateLocationSourceUseCase @Inject constructor( - private val locationsRepo: LocationsRepository, - private val weatherDataReconcilerRepository: WeatherDataReconcilerRepository -) { - /** - * Updates the weather, air quality, and alert sources for a location. - * - * @return An in-memory copy of the updated [Location]. - */ - suspend operator fun invoke( - location: Location, - source: Source, - airQualitySource: Source, - alertSource: Source, - openMeteoModel: OpenMeteoModel - ): Location { - val updatedLocation = location.copy( - source = source, - airQualitySource = airQualitySource, - alertSource = alertSource, - openMeteoModel = openMeteoModel - ) - - locationsRepo.updateSourceForLocation(location.id, source) - locationsRepo.updateAirQualitySourceForLocation(location.id, airQualitySource) - locationsRepo.updateAlertSourceForLocation(location.id, alertSource) - locationsRepo.updateOpenMeteoModelForLocation(location.id, openMeteoModel) - - weatherDataReconcilerRepository.reconcileSourceChange( - previous = location, - updated = updatedLocation - ) - - return updatedLocation - } -} \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/feature/daily/DailyScreenViewModel.kt b/app/src/main/java/com/pranshulgg/weather_master_app/feature/daily/DailyScreenViewModel.kt index 82e8eb26cb..0205935e40 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/feature/daily/DailyScreenViewModel.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/feature/daily/DailyScreenViewModel.kt @@ -7,9 +7,8 @@ import androidx.lifecycle.viewModelScope import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherBlock import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherUnits import com.pranshulgg.weather_master_app.data.repository.LocationsRepository +import com.pranshulgg.weather_master_app.data.repository.WeatherBlocksRepository import com.pranshulgg.weather_master_app.data.repository.WeatherUnitsRepository -import com.pranshulgg.weather_master_app.domain.usecase.LoadWeatherBlocksUseCase -import com.pranshulgg.weather_master_app.domain.usecase.SaveWeatherBlocksUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -17,9 +16,8 @@ import javax.inject.Inject @HiltViewModel class DailyScreenViewModel @Inject constructor( private val locationsRepo: LocationsRepository, - private val weatherUnitsRepository: WeatherUnitsRepository, - private val loadWeatherBlocksUseCase: LoadWeatherBlocksUseCase, - private val saveWeatherBlocksUseCase: SaveWeatherBlocksUseCase + private val weatherBlocksRepository: WeatherBlocksRepository, + private val weatherUnitsRepository: WeatherUnitsRepository ) : ViewModel() { private var _uiState = mutableStateOf(DailyScreenUiState()) @@ -41,19 +39,14 @@ class DailyScreenViewModel @Inject constructor( } + // TODO: Duplicate from `WeatherViewModel` fun loadBlocks() { viewModelScope.launch { - val loadedBlocks = loadWeatherBlocksUseCase(isDaily = true) + val loadedBlocks = weatherBlocksRepository.loadBlocks(isDaily = true) _uiState.value = _uiState.value.copy(blocks = loadedBlocks) } } - fun saveBlocks(blocks: List) { - viewModelScope.launch { - saveWeatherBlocksUseCase(blocks, isDaily = true) - } - } - fun updateBlocksOrder(blocks: List) { _uiState.value = _uiState.value.copy(blocks = blocks) } diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/feature/main/MainScreen.kt b/app/src/main/java/com/pranshulgg/weather_master_app/feature/main/MainScreen.kt index 8413eaacbe..20816e5e48 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/feature/main/MainScreen.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/feature/main/MainScreen.kt @@ -167,6 +167,7 @@ fun MainScreen(navController: NavController, weatherViewModel: WeatherViewModel) if (activeLocation != null) { weatherViewModel.getWeather( activeLocation, + activeLocation.source, isManualRefresh = true ) } @@ -199,4 +200,7 @@ fun MainScreen(navController: NavController, weatherViewModel: WeatherViewModel) onDismiss = viewModel::hideChangelogSheet, show = mainScreenUiState.isChangelogSheetOpen ) -} \ No newline at end of file +} + + + diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/feature/shared/WeatherViewModel.kt b/app/src/main/java/com/pranshulgg/weather_master_app/feature/shared/WeatherViewModel.kt index e21fcd1d0a..91bf4ceb66 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/feature/shared/WeatherViewModel.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/feature/shared/WeatherViewModel.kt @@ -22,13 +22,11 @@ import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertResult import com.pranshulgg.weather_master_app.core.model.weather.openmeteo.OpenMeteoModel import com.pranshulgg.weather_master_app.core.ui.snackbar.SnackbarManager import com.pranshulgg.weather_master_app.data.repository.LocationsRepository +import com.pranshulgg.weather_master_app.data.repository.WeatherBlocksRepository +import com.pranshulgg.weather_master_app.data.repository.WeatherDataReconcilerRepository import com.pranshulgg.weather_master_app.data.repository.WeatherUnitsRepository +import com.pranshulgg.weather_master_app.data.repository.data.SourceDataRepository import com.pranshulgg.weather_master_app.data.worker.WeatherBackgroundUpdateScheduler -import com.pranshulgg.weather_master_app.domain.usecase.DeleteLocationUseCase -import com.pranshulgg.weather_master_app.domain.usecase.GetWeatherUseCase -import com.pranshulgg.weather_master_app.domain.usecase.LoadWeatherBlocksUseCase -import com.pranshulgg.weather_master_app.domain.usecase.SaveWeatherBlocksUseCase -import com.pranshulgg.weather_master_app.domain.usecase.UpdateLocationSourceUseCase import com.pranshulgg.weather_master_app.feature.main.MainScreenWeatherUiState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -48,21 +46,15 @@ import kotlin.time.Duration.Companion.minutes class WeatherViewModel @Inject constructor( private val locationsRepo: LocationsRepository, appWeatherUnitsRepo: WeatherUnitsRepository, - private val getWeatherUseCase: GetWeatherUseCase, - private val deleteLocationUseCase: DeleteLocationUseCase, - private val updateLocationSourceUseCase: UpdateLocationSourceUseCase, - private val loadWeatherBlocksUseCase: LoadWeatherBlocksUseCase, - private val saveWeatherBlocksUseCase: SaveWeatherBlocksUseCase, + private val weatherBlocksRepository: WeatherBlocksRepository, + private val weatherDataReconcilerRepository: WeatherDataReconcilerRepository, + private val sourceDataRepository: SourceDataRepository, @ApplicationContext private val context: Context ) : ViewModel() { private var _uiState = mutableStateOf(MainScreenWeatherUiState()) val uiState: State = _uiState - companion object { - private val AUTO_REFRESH_INTERVAL = 45.minutes - } - // Registered on the process-wide lifecycle (same pattern as AppVisibility) rather than a // Compose LocalLifecycleOwner tied to a screen: a screen-scoped observer gets torn down and // recreated by ordinary in-app navigation, and Android replays a synthetic ON_START to any @@ -71,11 +63,11 @@ class WeatherViewModel @Inject constructor( // this ViewModel's lifetime, so this observer is only ever added once. private val processLifecycleObserver = object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) { - startAutoRefresh() val location = _uiState.value.activeLocation ?: return + startAutoRefresh(location = location, source = location.source) // isInitialized guard avoids duplicating setActiveLocation()'s cold-start fetch. if (_uiState.value.isInitialized) { - getWeather(location = location) + getWeather(location = location, source = location.source) } } @@ -138,6 +130,7 @@ class WeatherViewModel @Inject constructor( fun getWeather( location: Location, + source: Source, isManualRefresh: Boolean = false, isForceRefresh: Boolean = false, isForceRefreshForAirQuality: Boolean = false, @@ -155,16 +148,35 @@ class WeatherViewModel @Inject constructor( weatherJob = viewModelScope.launch { - getWeatherUseCase( - location = location, + var effectiveLocation = location + var effectiveForceRefresh = isForceRefresh + var effectiveForceRefreshForAirQuality = isForceRefreshForAirQuality + var effectiveForceRefreshForAlerts = isForceRefreshForAlerts + + // Checked regardless of isManualRefresh so it also runs on app-open/auto-refresh, + // not just pull-to-refresh. If the device actually moved, force a real fetch for + // the new coordinates instead of trusting a cache keyed to the old ones (note: + // isForceRefresh bypasses the cache unconditionally, unlike isManualRefresh, which + // only relaxes the cache TTL and would still be blocked by the 15-min throttle). + if (location.isDeviceLocation) { + val positionChanged = handleDeviceLocation() + if (positionChanged) { + effectiveLocation = locationsRepo.getLocationForId(location.id) + effectiveForceRefresh = true + effectiveForceRefreshForAirQuality = true + effectiveForceRefreshForAlerts = true + _uiState.value = _uiState.value.copy(activeLocation = effectiveLocation) + } + } + + + sourceDataRepository.getData( + location = effectiveLocation, isManualRefresh = isManualRefresh, - isForceRefresh = isForceRefresh, - isForceRefreshForAirQuality = isForceRefreshForAirQuality, - isForceRefreshForAlerts = isForceRefreshForAlerts, - onLocationUpdated = { updatedLocation -> - _uiState.value = _uiState.value.copy(activeLocation = updatedLocation) - }, - onWeather = { result, effectiveLocation -> + isForceRefresh = effectiveForceRefresh, + isForceRefreshForAirQuality = effectiveForceRefreshForAirQuality, + isForceRefreshForAlerts = effectiveForceRefreshForAlerts, + onWeather = { result -> handleWeatherData(result, effectiveLocation) }, onAlerts = { result -> @@ -174,6 +186,34 @@ class WeatherViewModel @Inject constructor( handleAirQuality(result) }, ) +// handleWeatherData(source, effectiveLocation, isManualRefresh, effectiveForceRefresh) + + // Run separately +// if (!_uiState.value.isError) { +// launch { +// handleAirQuality( +// effectiveLocation, +// isManualRefresh, +// effectiveForceRefreshForAirQuality +// ) +// } +// + + // Only run separately if the alert source is a different API of its own + // If not, then run after the weather has fetched +// if (!source.providesAlerts && effectiveLocation.alertSource.name != source.name) { +// launch { +// handleAlerts( +// effectiveLocation, +// isManualRefresh, +// effectiveForceRefreshForAlerts +// ) +// } +// } else { +// handleAlertsFromWeatherSource(effectiveLocation) +// } +// } + val elapsed = System.currentTimeMillis() - startTime val minLoadingTime = 1000L // 1s @@ -194,7 +234,7 @@ class WeatherViewModel @Inject constructor( fun deleteLocation(id: String) { viewModelScope.launch { - deleteLocationUseCase(id) + locationsRepo.deleteLocation(id) if (_uiState.value.activeLocation?.id == id) { setActiveLocation(_uiState.value.locations.first { it.isDefault }) @@ -209,7 +249,7 @@ class WeatherViewModel @Inject constructor( fun setActiveLocation(location: Location) { _uiState.value = _uiState.value.copy(activeLocation = location) - getWeather(location) + getWeather(location, location.source) } @@ -220,24 +260,33 @@ class WeatherViewModel @Inject constructor( alertSource: Source, openMeteoModel: OpenMeteoModel ) { + val updatedLocation = location.copy( + source = source, + airQualitySource = airQualitySource, + alertSource = alertSource, + openMeteoModel = openMeteoModel + ) + viewModelScope.launch { - val updatedLocation = updateLocationSourceUseCase( - location = location, - source = source, - airQualitySource = airQualitySource, - alertSource = alertSource, - openMeteoModel = openMeteoModel - ) + locationsRepo.updateSourceForLocation(location.id, source) + locationsRepo.updateAirQualitySourceForLocation(location.id, airQualitySource) + locationsRepo.updateAlertSourceForLocation(location.id, alertSource) + locationsRepo.updateOpenMeteoModelForLocation(location.id, openMeteoModel) val allowForceRefreshForWeather = location.source != source || location.openMeteoModel != openMeteoModel val allowForceRefreshForAirQuality = location.airQualitySource != airQualitySource val allowForceRefreshForAlerts = location.alertSource != alertSource + weatherDataReconcilerRepository.reconcileSourceChange( + previous = location, + updated = updatedLocation + ) _uiState.value = _uiState.value.copy(activeLocation = updatedLocation) getWeather( updatedLocation, + source, isForceRefresh = allowForceRefreshForWeather, isForceRefreshForAirQuality = allowForceRefreshForAirQuality, isForceRefreshForAlerts = allowForceRefreshForAlerts @@ -252,7 +301,15 @@ class WeatherViewModel @Inject constructor( ) { viewModelScope.launch { - saveWeatherBlocksUseCase(items, isDaily) + weatherBlocksRepository.saveBlocks(items.map { + WeatherBlock( + type = it.type, + isHidden = false, + position = it.position, + isDaily = isDaily, + id = it.id + ) + }, isDaily) } _uiState.value = _uiState.value.copy(blocks = items) @@ -260,10 +317,15 @@ class WeatherViewModel @Inject constructor( } suspend fun loadBlocks() { - val loadedBlocks = loadWeatherBlocksUseCase() + val loadedBlocks = weatherBlocksRepository.loadBlocks() _uiState.value = _uiState.value.copy(blocks = loadedBlocks) } + + private suspend fun handleDeviceLocation(): Boolean { + return locationsRepo.updateDeviceLocationPosition() + } + private suspend fun handleWeatherData(result: WeatherResult, location: Location) { @@ -275,8 +337,6 @@ class WeatherViewModel @Inject constructor( is WeatherResult.Error -> { - val appExpectation = result.exception.toAppException() - SnackbarManager.show(appExpectation.toMessageRes()) _uiState.value = _uiState.value.copy( isError = true, @@ -285,6 +345,9 @@ class WeatherViewModel @Inject constructor( location.countryCode?.uppercase() ) ) + + val appExpectation = result.exception.toAppException() + SnackbarManager.show(appExpectation.toMessageRes()) } is WeatherResult.RefreshNotAvailable -> { @@ -365,22 +428,24 @@ class WeatherViewModel @Inject constructor( private var autoRefreshJob: Job? = null - fun startAutoRefresh() { + fun startAutoRefresh( + location: Location, + source: Source + ) { if (autoRefreshJob?.isActive == true) return autoRefreshJob = viewModelScope.launch { while (isActive) { - delay(AUTO_REFRESH_INTERVAL) - val location = _uiState.value.activeLocation ?: continue - + delay(45.minutes) if (_uiState.value.isLoading || _uiState.value.isError) { continue } getWeather( - location = location + location = location, + source = source ) } } @@ -395,4 +460,4 @@ class WeatherViewModel @Inject constructor( super.onCleared() ProcessLifecycleOwner.get().lifecycle.removeObserver(processLifecycleObserver) } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/DeleteLocationUseCaseTest.kt b/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/DeleteLocationUseCaseTest.kt deleted file mode 100644 index 9aeecd6cbd..0000000000 --- a/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/DeleteLocationUseCaseTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.pranshulgg.weather_master_app.domain.usecase - -import com.pranshulgg.weather_master_app.data.repository.LocationsRepository -import io.mockk.coVerify -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class DeleteLocationUseCaseTest { - - private val repository: LocationsRepository = mockk(relaxed = true) - private val useCase = DeleteLocationUseCase(repository) - - @Test - fun `invoke should call deleteLocation on repository`() = runTest { - // Given - val locationId = "test_id" - - // When - useCase(locationId) - - // Then - coVerify { repository.deleteLocation(locationId) } - } -} \ No newline at end of file diff --git a/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/GetWeatherUseCaseTest.kt b/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/GetWeatherUseCaseTest.kt deleted file mode 100644 index 2c5efee158..0000000000 --- a/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/GetWeatherUseCaseTest.kt +++ /dev/null @@ -1,242 +0,0 @@ -package com.pranshulgg.weather_master_app.domain.usecase - -import com.pranshulgg.weather_master_app.core.model.domain.location.Location -import com.pranshulgg.weather_master_app.core.model.weather.WeatherResult -import com.pranshulgg.weather_master_app.data.repository.LocationsRepository -import com.pranshulgg.weather_master_app.data.repository.data.SourceDataRepository -import io.mockk.coEvery -import io.mockk.coJustRun -import io.mockk.coVerify -import io.mockk.mockk -import io.mockk.slot -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Test - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class GetWeatherUseCaseTest { - - private val locationsRepo: LocationsRepository = mockk() - private val sourceDataRepository: SourceDataRepository = mockk() - private val useCase = GetWeatherUseCase(locationsRepo, sourceDataRepository) - - private val dummyLocation = Location( - id = "1", - name = "London", - latitude = 51.5, - longitude = -0.12, - country = "UK", - timezone = "GMT", - countryCode = "GB", - state = "", - isDefault = true - ) - - @Test - fun `invoke should call sourceDataRepository getData`() = runTest { - // Given - coJustRun { - sourceDataRepository.getData( - location = dummyLocation, - isManualRefresh = any(), - isForceRefresh = any(), - isForceRefreshForAirQuality = any(), - isForceRefreshForAlerts = any(), - onWeather = any(), - onAlerts = any(), - onAirQuality = any() - ) - } - - // When - useCase( - location = dummyLocation, - onWeather = { _, _ -> }, - onAlerts = {}, - onAirQuality = {} - ) - - // Then - coVerify { - sourceDataRepository.getData( - location = dummyLocation, - isManualRefresh = false, - isForceRefresh = false, - isForceRefreshForAirQuality = false, - isForceRefreshForAlerts = false, - onWeather = any(), - onAlerts = any(), - onAirQuality = any() - ) - } - } - - @Test - fun `invoke with device location should update position and notify if changed`() = runTest { - // Given - val deviceLocation = dummyLocation.copy(isDeviceLocation = true) - val updatedLocation = deviceLocation.copy(latitude = 52.0) - var locationPassedToCallback: Location? = null - - coEvery { locationsRepo.updateDeviceLocationPosition() } returns true - coEvery { locationsRepo.getLocationForId(deviceLocation.id) } returns updatedLocation - coJustRun { - sourceDataRepository.getData( - location = updatedLocation, - isManualRefresh = any(), - isForceRefresh = any(), - isForceRefreshForAirQuality = any(), - isForceRefreshForAlerts = any(), - onWeather = any(), - onAlerts = any(), - onAirQuality = any() - ) - } - - // When - useCase( - location = deviceLocation, - onLocationUpdated = { locationPassedToCallback = it }, - onWeather = { _, _ -> }, - onAlerts = {}, - onAirQuality = {} - ) - - // Then - coVerify { locationsRepo.updateDeviceLocationPosition() } - assertEquals(updatedLocation, locationPassedToCallback) - coVerify { - sourceDataRepository.getData( - location = updatedLocation, - isManualRefresh = false, - isForceRefresh = true, - isForceRefreshForAirQuality = true, - isForceRefreshForAlerts = true, - onWeather = any(), - onAlerts = any(), - onAirQuality = any() - ) - } - } - - @Test - fun `invoke with device location should not update position if not changed`() = runTest { - // Given - val deviceLocation = dummyLocation.copy(isDeviceLocation = true) - - coEvery { locationsRepo.updateDeviceLocationPosition() } returns false - coJustRun { - sourceDataRepository.getData( - location = deviceLocation, - isManualRefresh = any(), - isForceRefresh = any(), - isForceRefreshForAirQuality = any(), - isForceRefreshForAlerts = any(), - onWeather = any(), - onAlerts = any(), - onAirQuality = any() - ) - } - - // When - useCase( - location = deviceLocation, - onWeather = { _, _ -> }, - onAlerts = {}, - onAirQuality = {} - ) - - // Then - coVerify { locationsRepo.updateDeviceLocationPosition() } - coVerify(exactly = 0) { locationsRepo.getLocationForId(any()) } - coVerify { - sourceDataRepository.getData( - location = deviceLocation, - isManualRefresh = false, - isForceRefresh = false, - isForceRefreshForAirQuality = false, - isForceRefreshForAlerts = false, - onWeather = any(), - onAlerts = any(), - onAirQuality = any() - ) - } - } - - @Test - fun `invoke should bubble up onWeather result`() = runTest { - // Given - val errorResult = WeatherResult.Error(Exception("Network error"), null) - var resultPassedToCallback: WeatherResult? = null - val onWeatherSlot = slot Unit>() - - coEvery { - sourceDataRepository.getData( - location = any(), - isManualRefresh = any(), - isForceRefresh = any(), - isForceRefreshForAirQuality = any(), - isForceRefreshForAlerts = any(), - onWeather = capture(onWeatherSlot), - onAlerts = any(), - onAirQuality = any() - ) - } coAnswers { - onWeatherSlot.captured(errorResult) - } - - // When - useCase( - location = dummyLocation, - onWeather = { result, _ -> resultPassedToCallback = result }, - onAlerts = {}, - onAirQuality = {} - ) - - // Then - assertEquals(errorResult, resultPassedToCallback) - } - - @Test - fun `invoke should respect cancellation`() = runTest { - // Given - var weatherCallbackInvoked = false - val onWeatherSlot = slot Unit>() - - coEvery { - sourceDataRepository.getData( - location = any(), - isManualRefresh = any(), - isForceRefresh = any(), - isForceRefreshForAirQuality = any(), - isForceRefreshForAlerts = any(), - onWeather = capture(onWeatherSlot), - onAlerts = any(), - onAirQuality = any() - ) - } coAnswers { - kotlinx.coroutines.delay(1000) - onWeatherSlot.captured(mockk()) - } - - // When - val job = launch { - useCase( - location = dummyLocation, - onWeather = { _, _ -> weatherCallbackInvoked = true }, - onAlerts = {}, - onAirQuality = {} - ) - } - - advanceTimeBy(500) - job.cancel() - advanceUntilIdle() - - // Then - assertEquals(false, weatherCallbackInvoked) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/LoadWeatherBlocksUseCaseTest.kt b/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/LoadWeatherBlocksUseCaseTest.kt deleted file mode 100644 index 7656367bc4..0000000000 --- a/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/LoadWeatherBlocksUseCaseTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.pranshulgg.weather_master_app.domain.usecase - -import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherBlock -import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherBlockType -import com.pranshulgg.weather_master_app.data.repository.WeatherBlocksRepository -import io.mockk.coEvery -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Test - -class LoadWeatherBlocksUseCaseTest { - - private val repository: WeatherBlocksRepository = mockk() - private val useCase = LoadWeatherBlocksUseCase(repository) - - @Test - fun `invoke should return blocks from repository`() = runTest { - // Given - val expectedBlocks = listOf( - WeatherBlock(id = 1, isDaily = false, type = WeatherBlockType.HUMIDITY_BLOCK, isHidden = false, position = 0) - ) - coEvery { repository.loadBlocks(false) } returns expectedBlocks - - // When - val result = useCase(isDaily = false) - - // Then - assertEquals(expectedBlocks, result) - } - - @Test - fun `invoke with isDaily true should return daily blocks from repository`() = runTest { - // Given - val expectedBlocks = listOf( - WeatherBlock(id = 2, isDaily = true, type = WeatherBlockType.WIND_BLOCK, isHidden = false, position = 0) - ) - coEvery { repository.loadBlocks(true) } returns expectedBlocks - - // When - val result = useCase(isDaily = true) - - // Then - assertEquals(expectedBlocks, result) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/SaveWeatherBlocksUseCaseTest.kt b/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/SaveWeatherBlocksUseCaseTest.kt deleted file mode 100644 index 33d673a71a..0000000000 --- a/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/SaveWeatherBlocksUseCaseTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.pranshulgg.weather_master_app.domain.usecase - -import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherBlock -import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherBlockType -import com.pranshulgg.weather_master_app.data.repository.WeatherBlocksRepository -import io.mockk.coVerify -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class SaveWeatherBlocksUseCaseTest { - - private val repository: WeatherBlocksRepository = mockk(relaxed = true) - private val useCase = SaveWeatherBlocksUseCase(repository) - - @Test - fun `invoke should call saveBlocks on repository`() = runTest { - // Given - val blocks = listOf( - WeatherBlock(id = 1, isDaily = false, type = WeatherBlockType.HUMIDITY_BLOCK, isHidden = false, position = 0) - ) - - // When - useCase(blocks, isDaily = false) - - // Then - coVerify { repository.saveBlocks(blocks, false) } - } - - @Test - fun `invoke with isDaily true should call saveBlocks with isDaily true`() = runTest { - // Given - val blocks = listOf( - WeatherBlock(id = 2, isDaily = true, type = WeatherBlockType.WIND_BLOCK, isHidden = false, position = 0) - ) - - // When - useCase(blocks, isDaily = true) - - // Then - coVerify { repository.saveBlocks(blocks, true) } - } -} \ No newline at end of file diff --git a/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/UpdateLocationSourceUseCaseTest.kt b/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/UpdateLocationSourceUseCaseTest.kt deleted file mode 100644 index aef4017074..0000000000 --- a/app/src/test/java/com/pranshulgg/weather_master_app/domain/usecase/UpdateLocationSourceUseCaseTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.pranshulgg.weather_master_app.domain.usecase - -import com.pranshulgg.weather_master_app.core.model.domain.location.Location -import com.pranshulgg.weather_master_app.core.model.sources.Source -import com.pranshulgg.weather_master_app.core.model.weather.openmeteo.OpenMeteoModel -import com.pranshulgg.weather_master_app.data.repository.LocationsRepository -import com.pranshulgg.weather_master_app.data.repository.WeatherDataReconcilerRepository -import io.mockk.coVerify -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Test - -class UpdateLocationSourceUseCaseTest { - - private val locationsRepo: LocationsRepository = mockk(relaxed = true) - private val weatherDataReconcilerRepository: WeatherDataReconcilerRepository = mockk(relaxed = true) - private val useCase = UpdateLocationSourceUseCase(locationsRepo, weatherDataReconcilerRepository) - - private val dummyLocation = Location( - id = "1", - name = "London", - latitude = 51.5, - longitude = -0.12, - country = "UK", - timezone = "GMT", - countryCode = "GB", - state = "", - isDefault = true, - source = Source.OPEN_METEO - ) - - @Test - fun `invoke should update location sources and reconcile data`() = runTest { - // Given - val newSource = Source.MET_NORWAY - val newAirQualitySource = Source.ACCU_WEATHER - val newAlertSource = Source.NWS - val newModel = OpenMeteoModel.BEST_MATCH - - // When - val result = useCase( - location = dummyLocation, - source = newSource, - airQualitySource = newAirQualitySource, - alertSource = newAlertSource, - openMeteoModel = newModel - ) - - // Then - coVerify { locationsRepo.updateSourceForLocation(dummyLocation.id, newSource) } - coVerify { locationsRepo.updateAirQualitySourceForLocation(dummyLocation.id, newAirQualitySource) } - coVerify { locationsRepo.updateAlertSourceForLocation(dummyLocation.id, newAlertSource) } - coVerify { locationsRepo.updateOpenMeteoModelForLocation(dummyLocation.id, newModel) } - - coVerify { - weatherDataReconcilerRepository.reconcileSourceChange( - previous = dummyLocation, - updated = any() - ) - } - - assertEquals(newSource, result.source) - assertEquals(newAirQualitySource, result.airQualitySource) - assertEquals(newAlertSource, result.alertSource) - assertEquals(newModel, result.openMeteoModel) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/pranshulgg/weather_master_app/feature/daily/DailyScreenViewModelTest.kt b/app/src/test/java/com/pranshulgg/weather_master_app/feature/daily/DailyScreenViewModelTest.kt deleted file mode 100644 index bb73af264f..0000000000 --- a/app/src/test/java/com/pranshulgg/weather_master_app/feature/daily/DailyScreenViewModelTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.pranshulgg.weather_master_app.feature.daily - -import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherBlock -import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherBlockType -import com.pranshulgg.weather_master_app.data.repository.LocationsRepository -import com.pranshulgg.weather_master_app.data.repository.WeatherUnitsRepository -import com.pranshulgg.weather_master_app.domain.usecase.LoadWeatherBlocksUseCase -import com.pranshulgg.weather_master_app.domain.usecase.SaveWeatherBlocksUseCase -import io.mockk.coJustRun -import io.mockk.coVerify -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test - -@kotlinx.coroutines.ExperimentalCoroutinesApi -class DailyScreenViewModelTest { - - private val locationsRepo: LocationsRepository = mockk() - private val weatherUnitsRepository: WeatherUnitsRepository = mockk() - private val loadWeatherBlocksUseCase: LoadWeatherBlocksUseCase = mockk() - private val saveWeatherBlocksUseCase: SaveWeatherBlocksUseCase = mockk() - - private lateinit var viewModel: DailyScreenViewModel - - private val testDispatcher = StandardTestDispatcher() - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - viewModel = DailyScreenViewModel( - locationsRepo, - weatherUnitsRepository, - loadWeatherBlocksUseCase, - saveWeatherBlocksUseCase - ) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `saveBlocks should call saveWeatherBlocksUseCase with isDaily true`() = runTest { - // Given - val blocks = listOf( - WeatherBlock(id = 1, isDaily = true, type = WeatherBlockType.HUMIDITY_BLOCK, isHidden = false, position = 0) - ) - coJustRun { saveWeatherBlocksUseCase(blocks, true) } - - // When - viewModel.saveBlocks(blocks) - - // Then - coVerify { saveWeatherBlocksUseCase(blocks, true) } - } -} \ No newline at end of file diff --git a/app/src/test/java/com/pranshulgg/weather_master_app/feature/shared/WeatherViewModelTest.kt b/app/src/test/java/com/pranshulgg/weather_master_app/feature/shared/WeatherViewModelTest.kt deleted file mode 100644 index 7a5dfbd217..0000000000 --- a/app/src/test/java/com/pranshulgg/weather_master_app/feature/shared/WeatherViewModelTest.kt +++ /dev/null @@ -1,221 +0,0 @@ -package com.pranshulgg.weather_master_app.feature.shared - -import android.content.Context -import com.pranshulgg.weather_master_app.core.model.domain.location.Location -import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherUnits -import com.pranshulgg.weather_master_app.data.repository.LocationsRepository -import com.pranshulgg.weather_master_app.data.repository.WeatherUnitsRepository -import com.pranshulgg.weather_master_app.domain.usecase.* -import io.mockk.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.* -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test -import kotlin.time.Duration.Companion.minutes - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class WeatherViewModelTest { - - private val locationsRepo: LocationsRepository = mockk() - private val appWeatherUnitsRepo: WeatherUnitsRepository = mockk() - private val getWeatherUseCase: GetWeatherUseCase = mockk() - private val deleteLocationUseCase: DeleteLocationUseCase = mockk() - private val updateLocationSourceUseCase: UpdateLocationSourceUseCase = mockk() - private val loadWeatherBlocksUseCase: LoadWeatherBlocksUseCase = mockk() - private val saveWeatherBlocksUseCase: SaveWeatherBlocksUseCase = mockk() - private val context: Context = mockk() - - private lateinit var viewModel: WeatherViewModel - - private val testDispatcher = StandardTestDispatcher() - - private val dummyLocation = Location( - id = "1", - name = "London", - latitude = 51.5, - longitude = -0.12, - country = "UK", - timezone = "GMT", - countryCode = "GB", - state = "", - isDefault = true - ) - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - - every { locationsRepo.getLocations() } returns flowOf(emptyList()) - every { appWeatherUnitsRepo.getUnits() } returns flowOf(WeatherUnits.getDefault()) - every { locationsRepo.getDefaultLocation() } returns flowOf(dummyLocation) - coEvery { locationsRepo.isLocationsEmpty() } returns false - coEvery { loadWeatherBlocksUseCase() } returns emptyList() - coJustRun { - getWeatherUseCase( - location = any(), - isManualRefresh = any(), - isForceRefresh = any(), - isForceRefreshForAirQuality = any(), - isForceRefreshForAlerts = any(), - onLocationUpdated = any(), - onWeather = any(), - onAlerts = any(), - onAirQuality = any() - ) - } - - viewModel = WeatherViewModel( - locationsRepo, - appWeatherUnitsRepo, - getWeatherUseCase, - deleteLocationUseCase, - updateLocationSourceUseCase, - loadWeatherBlocksUseCase, - saveWeatherBlocksUseCase, - context - ) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `setActiveLocation should call getWeatherUseCase`() = runTest { - // When - viewModel.setActiveLocation(dummyLocation) - advanceUntilIdle() - - // Then - coVerify { - getWeatherUseCase( - location = dummyLocation, - isManualRefresh = false, - isForceRefresh = false, - isForceRefreshForAirQuality = false, - isForceRefreshForAlerts = false, - onLocationUpdated = any(), - onWeather = any(), - onAlerts = any(), - onAirQuality = any() - ) - } - } - - @Test - fun `deleteLocation should call deleteLocationUseCase`() = runTest { - // Given - coJustRun { deleteLocationUseCase(any()) } - - // When - viewModel.deleteLocation("1") - advanceUntilIdle() - - // Then - coVerify { deleteLocationUseCase("1") } - } - - @Test - fun `device location update should update activeLocation in state`() = runTest { - // Given - val deviceLocation = dummyLocation.copy(isDeviceLocation = true) - val updatedLocation = deviceLocation.copy(latitude = 52.0) - val onLocationUpdatedSlot = slot Unit>() - - coEvery { - getWeatherUseCase( - location = any(), - isManualRefresh = any(), - isForceRefresh = any(), - isForceRefreshForAirQuality = any(), - isForceRefreshForAlerts = any(), - onLocationUpdated = capture(onLocationUpdatedSlot), - onWeather = any(), - onAlerts = any(), - onAirQuality = any() - ) - } coAnswers { - onLocationUpdatedSlot.captured(updatedLocation) - } - - // When - viewModel.setActiveLocation(deviceLocation) - advanceUntilIdle() - - // Then - assertEquals(updatedLocation, viewModel.uiState.value.activeLocation) - } - - @Test - fun `auto refresh should call getWeather periodically`() = runTest { - // Given - viewModel.setActiveLocation(dummyLocation) - advanceUntilIdle() - - // Clear initial call from setActiveLocation - clearMocks(getWeatherUseCase, answers = false) - - // When - advanceTimeBy(45.minutes.inWholeMilliseconds + 1) - - // Then - coVerify { - getWeatherUseCase( - location = dummyLocation, - isManualRefresh = false, - isForceRefresh = false, - isForceRefreshForAirQuality = false, - isForceRefreshForAlerts = false, - onLocationUpdated = any(), - onWeather = any(), - onAlerts = any(), - onAirQuality = any() - ) - } - } - - @Test - fun `handleSourceChangeForWeather should compute force refresh flags correctly`() = runTest { - // Given - val newSource = com.pranshulgg.weather_master_app.core.model.sources.Source.MET_NORWAY - val updatedLocation = dummyLocation.copy(source = newSource) - - coEvery { - updateLocationSourceUseCase( - location = any(), - source = any(), - airQualitySource = any(), - alertSource = any(), - openMeteoModel = any() - ) - } returns updatedLocation - - // When - viewModel.handleSourceChangeForWeather( - location = dummyLocation, - source = newSource, - airQualitySource = dummyLocation.airQualitySource, - alertSource = dummyLocation.alertSource, - openMeteoModel = dummyLocation.openMeteoModel - ) - advanceUntilIdle() - - // Then - coVerify { - getWeatherUseCase( - location = updatedLocation, - isForceRefresh = true, // Source changed - isForceRefreshForAirQuality = false, - isForceRefreshForAlerts = false, - onLocationUpdated = any(), - onWeather = any(), - onAlerts = any(), - onAirQuality = any() - ) - } - } -} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4915457f46..23138cb189 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,8 +31,6 @@ hiltWork = "1.4.0" hiltCompiler = "1.4.0" lifecycleProcess = "2.11.0" kotlinMetadataJvm = "2.4.0" -mockk = "1.14.11" -turbine = "1.2.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -78,10 +76,8 @@ hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltCompiler" } androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" } hilt-metadata-jvm = { module = "org.jetbrains.kotlin:kotlin-metadata-jvm", version.ref = "kotlinMetadataJvm" } -mockk = { module = "io.mockk:mockk", version.ref = "mockk" } -kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } -turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } \ No newline at end of file +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +