From 6e90956b3ea3148abc3b3c289c5265541c3a01f6 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 9 Jul 2026 20:10:31 +0000 Subject: [PATCH] Add logging throughout the Android app and unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log.d/i/w/e calls covering location resolution, TTS lifecycle and errors, nearby/search network calls and their failure paths, and screen-level state transitions. OkHttp's logging interceptor now routes through Log (tag "OkHttp") instead of println for consistent filtering. Enabled testOptions.unitTests.isReturnDefaultValues so android.util.Log calls don't crash plain JVM unit tests. Added mockk + kotlinx-coroutines-test and a GuideViewModelTest suite covering the load-success, load-failure, and retry paths — including a regression test for the infinite-spinner bug (isLoading must clear and errorMessage must be set on a failed nearby-search call, not left hanging). Verified: ./gradlew testDebugUnitTest passes (4/4). Co-Authored-By: Claude Sonnet 5 --- android/app/build.gradle.kts | 10 ++ .../java/com/guidecity/app/GuideCityApp.kt | 10 +- .../java/com/guidecity/app/MainActivity.kt | 4 + .../app/data/remote/RetrofitClient.kt | 6 +- .../app/location/LocationProvider.kt | 33 ++++- .../java/com/guidecity/app/tts/TtsManager.kt | 35 ++++- .../app/ui/detail/PlaceDetailViewModel.kt | 11 +- .../app/ui/favorites/FavoritesViewModel.kt | 5 + .../guidecity/app/ui/guide/GuideViewModel.kt | 18 +++ .../com/guidecity/app/ui/map/MapViewModel.kt | 22 ++- .../app/ui/onboarding/OnboardingViewModel.kt | 4 + .../ui/preference/ContentLengthViewModel.kt | 5 + .../app/ui/settings/SettingsViewModel.kt | 4 + .../com/guidecity/app/MainDispatcherRule.kt | 24 ++++ .../app/data/local/prefs/ContentLengthTest.kt | 14 ++ .../app/ui/guide/GuideViewModelTest.kt | 132 ++++++++++++++++++ android/gradle/libs.versions.toml | 4 + 17 files changed, 323 insertions(+), 18 deletions(-) create mode 100644 android/app/src/test/java/com/guidecity/app/MainDispatcherRule.kt create mode 100644 android/app/src/test/java/com/guidecity/app/data/local/prefs/ContentLengthTest.kt create mode 100644 android/app/src/test/java/com/guidecity/app/ui/guide/GuideViewModelTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 85db5b1..92914c7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -67,6 +67,14 @@ android { compose = true buildConfig = true } + + testOptions { + unitTests { + // Production code calls android.util.Log for observability; without this, + // the Android stub jar throws on every such call in plain JVM unit tests. + isReturnDefaultValues = true + } + } } dependencies { @@ -107,6 +115,8 @@ dependencies { // isolated integration point in the meantime. testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt b/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt index deece02..1c3c302 100644 --- a/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt +++ b/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt @@ -1,7 +1,15 @@ package com.guidecity.app import android.app.Application +import android.util.Log import dagger.hilt.android.HiltAndroidApp +private const val TAG = "GuideCityApp" + @HiltAndroidApp -class GuideCityApp : Application() +class GuideCityApp : Application() { + override fun onCreate() { + super.onCreate() + Log.i(TAG, "Application created (debug=${BuildConfig.DEBUG}, apiBaseUrl=${BuildConfig.API_BASE_URL})") + } +} diff --git a/android/app/src/main/java/com/guidecity/app/MainActivity.kt b/android/app/src/main/java/com/guidecity/app/MainActivity.kt index 8920f00..cbd919a 100644 --- a/android/app/src/main/java/com/guidecity/app/MainActivity.kt +++ b/android/app/src/main/java/com/guidecity/app/MainActivity.kt @@ -1,6 +1,7 @@ package com.guidecity.app import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -8,10 +9,13 @@ import com.guidecity.app.navigation.GuideCityNavHost import com.guidecity.app.theme.GuideCityTheme import dagger.hilt.android.AndroidEntryPoint +private const val TAG = "MainActivity" + @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + Log.d(TAG, "onCreate") enableEdgeToEdge() setContent { GuideCityTheme { diff --git a/android/app/src/main/java/com/guidecity/app/data/remote/RetrofitClient.kt b/android/app/src/main/java/com/guidecity/app/data/remote/RetrofitClient.kt index 198ff83..e276466 100644 --- a/android/app/src/main/java/com/guidecity/app/data/remote/RetrofitClient.kt +++ b/android/app/src/main/java/com/guidecity/app/data/remote/RetrofitClient.kt @@ -1,5 +1,6 @@ package com.guidecity.app.data.remote +import android.util.Log import com.guidecity.app.BuildConfig import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -8,6 +9,8 @@ import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory +private const val TAG = "OkHttp" + object RetrofitClient { private val json = Json { @@ -18,7 +21,7 @@ object RetrofitClient { private val okHttpClient: OkHttpClient by lazy { OkHttpClient.Builder() .addInterceptor( - HttpLoggingInterceptor().apply { + HttpLoggingInterceptor { message -> Log.d(TAG, message) }.apply { level = if (BuildConfig.DEBUG) { HttpLoggingInterceptor.Level.BODY } else { @@ -30,6 +33,7 @@ object RetrofitClient { } val apiService: ApiService by lazy { + Log.i(TAG, "creating Retrofit client, baseUrl=${BuildConfig.API_BASE_URL}") Retrofit.Builder() .baseUrl(BuildConfig.API_BASE_URL) .client(okHttpClient) diff --git a/android/app/src/main/java/com/guidecity/app/location/LocationProvider.kt b/android/app/src/main/java/com/guidecity/app/location/LocationProvider.kt index c62bac0..5eeb5f9 100644 --- a/android/app/src/main/java/com/guidecity/app/location/LocationProvider.kt +++ b/android/app/src/main/java/com/guidecity/app/location/LocationProvider.kt @@ -4,6 +4,7 @@ import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageManager +import android.util.Log import androidx.core.content.ContextCompat import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices @@ -13,6 +14,8 @@ import kotlinx.coroutines.tasks.await import javax.inject.Inject import javax.inject.Singleton +private const val TAG = "LocationProvider" + data class LatLon(val lat: Double, val lon: Double) @Singleton @@ -26,18 +29,34 @@ class LocationProvider @Inject constructor( fun hasLocationPermission(): Boolean { val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) - return fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED + val granted = fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED + Log.d(TAG, "hasLocationPermission: fine=$fine coarse=$coarse -> $granted") + return granted } /** Returns null if permission isn't granted or no location could be resolved. */ @SuppressLint("MissingPermission") suspend fun getCurrentLocation(): LatLon? { - if (!hasLocationPermission()) return null + if (!hasLocationPermission()) { + Log.w(TAG, "getCurrentLocation: permission not granted, returning null") + return null + } - val current = fusedClient - .getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) - .await() - val location = current ?: fusedClient.lastLocation.await() - return location?.let { LatLon(it.latitude, it.longitude) } + return try { + val current = fusedClient + .getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) + .await() + val location = current ?: fusedClient.lastLocation.await() + if (location == null) { + Log.w(TAG, "getCurrentLocation: no location available from fused client (current or last)") + null + } else { + Log.i(TAG, "getCurrentLocation: resolved lat=${location.latitude} lon=${location.longitude}") + LatLon(location.latitude, location.longitude) + } + } catch (e: Exception) { + Log.e(TAG, "getCurrentLocation failed", e) + null + } } } diff --git a/android/app/src/main/java/com/guidecity/app/tts/TtsManager.kt b/android/app/src/main/java/com/guidecity/app/tts/TtsManager.kt index 7a1c145..6cb6bac 100644 --- a/android/app/src/main/java/com/guidecity/app/tts/TtsManager.kt +++ b/android/app/src/main/java/com/guidecity/app/tts/TtsManager.kt @@ -3,12 +3,15 @@ package com.guidecity.app.tts import android.content.Context import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener +import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import java.util.Locale import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +private const val TAG = "TtsManager" + /** * Wraps Android's built-in, on-device [TextToSpeech] engine — this satisfies * the app's "local voice narration" requirement without a custom ML model. @@ -32,29 +35,44 @@ class TtsManager @Inject constructor( private val tts: TextToSpeech = TextToSpeech(context) { status -> isReady = status == TextToSpeech.SUCCESS - pendingUtterance?.let { (text, onDone) -> speakInternal(text, onDone) } + if (!isReady) { + Log.e(TAG, "TextToSpeech engine init failed, status=$status") + } else { + Log.i(TAG, "TextToSpeech engine ready") + } + pendingUtterance?.let { (text, onDone) -> + Log.d(TAG, "flushing pending utterance queued before engine was ready") + speakInternal(text, onDone) + } pendingUtterance = null }.apply { setOnUtteranceProgressListener( object : UtteranceProgressListener() { - override fun onStart(utteranceId: String?) = Unit + override fun onStart(utteranceId: String?) { + Log.d(TAG, "utterance started: $utteranceId") + } override fun onDone(utteranceId: String?) { + Log.d(TAG, "utterance done: $utteranceId") onDoneCallback?.invoke() } @Deprecated("Deprecated in Java, but still the callback the platform invokes") - override fun onError(utteranceId: String?) = Unit + override fun onError(utteranceId: String?) { + Log.e(TAG, "utterance error: $utteranceId") + } }, ) } fun setLanguage(locale: Locale) { - tts.language = locale + val result = tts.setLanguage(locale) + Log.d(TAG, "setLanguage($locale) -> $result") } /** Speaks [text], calling [onDone] on the main thread once narration finishes. */ fun speak(text: String, onDone: (() -> Unit)? = null) { + Log.d(TAG, "speak() called, muted=$isMuted ready=$isReady length=${text.length}") lastSpokenText = text onDoneCallback = onDone if (isMuted) return @@ -67,26 +85,33 @@ class TtsManager @Inject constructor( private fun speakInternal(text: String, onDone: (() -> Unit)?) { onDoneCallback = onDone - tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, UUID.randomUUID().toString()) + val result = tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, UUID.randomUUID().toString()) + if (result != TextToSpeech.SUCCESS) { + Log.e(TAG, "tts.speak() returned error code $result") + } } /** Stops narration and suppresses further [speak] calls until [unmute]. */ fun mute() { + Log.d(TAG, "mute()") isMuted = true tts.stop() } /** Resumes narration from the start of the last spoken text. */ fun unmute() { + Log.d(TAG, "unmute()") isMuted = false lastSpokenText?.let { speakInternal(it, onDoneCallback) } } fun stop() { + Log.d(TAG, "stop()") tts.stop() } fun shutdown() { + Log.d(TAG, "shutdown()") tts.shutdown() } } diff --git a/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt index 5253e79..3c62735 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt @@ -1,5 +1,6 @@ package com.guidecity.app.ui.detail +import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -20,6 +21,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "PlaceDetailViewModel" + data class PlaceDetailUiState( val isLoading: Boolean = true, val place: PlaceDetailDto? = null, @@ -52,9 +55,13 @@ class PlaceDetailViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) init { + Log.d(TAG, "init: placeId=$placeId") viewModelScope.launch { val length = (userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM).toApiValue() - val place = runCatching { placesRepository.getPlace(placeId, lang = "ru", length = length) }.getOrNull() + val place = runCatching { placesRepository.getPlace(placeId, lang = "ru", length = length) } + .onFailure { Log.e(TAG, "getPlace($placeId) failed", it) } + .getOrNull() + Log.d(TAG, "loaded place=${place?.slug}") _uiState.value = _uiState.value.copy(isLoading = false, place = place) place?.let { ttsManager.speak(it.content.body) } } @@ -62,11 +69,13 @@ class PlaceDetailViewModel @Inject constructor( fun toggleMute() { if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute() + Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}") _uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted) } fun toggleFavorite() { val place = _uiState.value.place ?: return + Log.d(TAG, "toggleFavorite: placeId=$placeId currentlyFavorite=${isFavorite.value}") viewModelScope.launch { if (isFavorite.value) { favoritesRepository.removeFavorite(placeId) diff --git a/android/app/src/main/java/com/guidecity/app/ui/favorites/FavoritesViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/favorites/FavoritesViewModel.kt index aa6e104..32af551 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/favorites/FavoritesViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/favorites/FavoritesViewModel.kt @@ -1,5 +1,6 @@ package com.guidecity.app.ui.favorites +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.guidecity.app.data.local.db.FavoritePlaceEntity @@ -7,13 +8,17 @@ import com.guidecity.app.data.repository.FavoritesRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import javax.inject.Inject +private const val TAG = "FavoritesViewModel" + @HiltViewModel class FavoritesViewModel @Inject constructor( favoritesRepository: FavoritesRepository, ) : ViewModel() { val favorites: StateFlow> = favoritesRepository.observeFavorites() + .onEach { Log.d(TAG, "favorites updated: ${it.size} item(s)") } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) } diff --git a/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt index f43ecbf..8f5d7e9 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt @@ -1,5 +1,6 @@ package com.guidecity.app.ui.guide +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.guidecity.app.data.local.prefs.ContentLength @@ -20,6 +21,8 @@ import kotlinx.coroutines.launch import java.util.Locale import javax.inject.Inject +private const val TAG = "GuideViewModel" + data class GuideUiState( val isLoading: Boolean = true, val places: List = emptyList(), @@ -54,6 +57,7 @@ class GuideViewModel @Inject constructor( init { viewModelScope.launch { contentLength = userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM + Log.i(TAG, "init: contentLength=$contentLength") ttsManager.setLanguage(Locale("ru")) loadNearby(startNarration = true) startRescanLoop() @@ -63,9 +67,11 @@ class GuideViewModel @Inject constructor( private fun startRescanLoop() { if (rescanStarted) return rescanStarted = true + Log.d(TAG, "starting 60s re-scan loop") viewModelScope.launch { while (isActive) { delay(60_000) + Log.d(TAG, "re-scan tick") loadNearby(startNarration = false) } } @@ -74,10 +80,13 @@ class GuideViewModel @Inject constructor( private suspend fun loadNearby(startNarration: Boolean) { val location = selectedLocationHolder.location.value ?: locationProvider.getCurrentLocation() if (location == null) { + Log.w(TAG, "loadNearby: no location available") _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "Location unavailable") return } + Log.d(TAG, "loadNearby: lat=${location.lat} lon=${location.lon} length=${contentLength.toApiValue()}") + runCatching { placesRepository.getNearby( lat = location.lat, @@ -87,6 +96,7 @@ class GuideViewModel @Inject constructor( length = contentLength.toApiValue(), ) }.onSuccess { response -> + Log.i(TAG, "loadNearby success: ${response.places.size} place(s), radius=${response.searchRadiusM}m") _uiState.value = _uiState.value.copy( isLoading = false, places = response.places, @@ -98,6 +108,7 @@ class GuideViewModel @Inject constructor( narrateActive() } }.onFailure { error -> + Log.e(TAG, "loadNearby failed", error) _uiState.value = _uiState.value.copy( isLoading = false, errorMessage = error.message ?: "Network error", @@ -107,6 +118,7 @@ class GuideViewModel @Inject constructor( /** Retries after a failed load (e.g. tap a "retry" button shown on error). */ fun retry() { + Log.d(TAG, "retry() called") viewModelScope.launch { _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) loadNearby(startNarration = true) @@ -116,13 +128,16 @@ class GuideViewModel @Inject constructor( private fun narrateActive() { val state = _uiState.value val place = state.places.getOrNull(state.activeIndex) ?: return + Log.d(TAG, "narrateActive: index=${state.activeIndex} placeId=${place.id} slug=${place.slug}") ttsManager.speak(place.content.body) { onNarrationDone() } } private fun onNarrationDone() { val state = _uiState.value val nextIndex = state.activeIndex + 1 + Log.d(TAG, "onNarrationDone: finished index=${state.activeIndex}, nextIndex=$nextIndex") if (nextIndex >= state.places.size) { + Log.i(TAG, "onNarrationDone: reached end of nearby list") _uiState.value = state.copy(doneIndices = state.doneIndices + state.activeIndex) return } @@ -137,17 +152,20 @@ class GuideViewModel @Inject constructor( fun setActiveIndex(index: Int) { val state = _uiState.value if (index !in state.places.indices || index == state.activeIndex) return + Log.d(TAG, "setActiveIndex: ${state.activeIndex} -> $index (manual swipe)") _uiState.value = state.copy(activeIndex = index) narrateActive() } fun toggleMute() { if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute() + Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}") _uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted) } override fun onCleared() { super.onCleared() + Log.d(TAG, "onCleared") ttsManager.stop() } } diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt index 92df0f7..83e4287 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt @@ -1,5 +1,6 @@ package com.guidecity.app.ui.map +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.guidecity.app.data.remote.dto.PlaceListItemDto @@ -14,6 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "MapViewModel" + sealed interface MapUiState { data object CheckingPermission : MapUiState data object NeedsPermission : MapUiState @@ -35,7 +38,9 @@ class MapViewModel @Inject constructor( val searchResults: StateFlow> = _searchResults.asStateFlow() fun checkInitialPermission(onLocationResolved: () -> Unit) { - if (locationProvider.hasLocationPermission()) { + val hasPermission = locationProvider.hasLocationPermission() + Log.d(TAG, "checkInitialPermission: hasPermission=$hasPermission") + if (hasPermission) { resolveLocation(onLocationResolved) } else { _uiState.value = MapUiState.NeedsPermission @@ -43,6 +48,7 @@ class MapViewModel @Inject constructor( } fun onPermissionResult(granted: Boolean, onLocationResolved: () -> Unit) { + Log.i(TAG, "onPermissionResult: granted=$granted") if (granted) { resolveLocation(onLocationResolved) } else { @@ -55,9 +61,11 @@ class MapViewModel @Inject constructor( viewModelScope.launch { val location = locationProvider.getCurrentLocation() if (location != null) { + Log.i(TAG, "resolveLocation: got lat=${location.lat} lon=${location.lon}") selectedLocationHolder.set(location) onLocationResolved() } else { + Log.w(TAG, "resolveLocation: location unavailable, falling back to search") _uiState.value = MapUiState.SearchFallback } } @@ -68,14 +76,22 @@ class MapViewModel @Inject constructor( _searchResults.value = emptyList() return } + Log.d(TAG, "searchPlaces: query=$query") viewModelScope.launch { - _searchResults.value = runCatching { + runCatching { placesRepository.searchPlaces(query, citySlug = "moscow", lang = "ru") - }.getOrDefault(emptyList()) + }.onSuccess { results -> + Log.d(TAG, "searchPlaces: ${results.size} result(s) for query=$query") + _searchResults.value = results + }.onFailure { error -> + Log.e(TAG, "searchPlaces failed for query=$query", error) + _searchResults.value = emptyList() + } } } fun selectSearchResult(place: PlaceListItemDto, onLocationResolved: () -> Unit) { + Log.i(TAG, "selectSearchResult: placeId=${place.id} slug=${place.slug}") selectedLocationHolder.set(LatLon(place.location.lat, place.location.lon)) onLocationResolved() } diff --git a/android/app/src/main/java/com/guidecity/app/ui/onboarding/OnboardingViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/onboarding/OnboardingViewModel.kt index 219c432..f550a49 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/onboarding/OnboardingViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/onboarding/OnboardingViewModel.kt @@ -1,5 +1,6 @@ package com.guidecity.app.ui.onboarding +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.guidecity.app.data.local.prefs.UserPrefsDataStore @@ -7,12 +8,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "OnboardingViewModel" + @HiltViewModel class OnboardingViewModel @Inject constructor( private val userPrefsDataStore: UserPrefsDataStore, ) : ViewModel() { fun markOnboardingSeen(onDone: () -> Unit) { viewModelScope.launch { + Log.i(TAG, "markOnboardingSeen") userPrefsDataStore.setOnboardingSeen(true) onDone() } diff --git a/android/app/src/main/java/com/guidecity/app/ui/preference/ContentLengthViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/preference/ContentLengthViewModel.kt index 7310fe2..6cca989 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/preference/ContentLengthViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/preference/ContentLengthViewModel.kt @@ -1,5 +1,6 @@ package com.guidecity.app.ui.preference +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.guidecity.app.data.local.prefs.ContentLength @@ -11,6 +12,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "ContentLengthViewModel" + @HiltViewModel class ContentLengthViewModel @Inject constructor( private val userPrefsDataStore: UserPrefsDataStore, @@ -20,11 +23,13 @@ class ContentLengthViewModel @Inject constructor( val selected: StateFlow = _selected.asStateFlow() fun select(length: ContentLength) { + Log.d(TAG, "select: $length") _selected.value = length } fun confirmSelection(onDone: () -> Unit) { viewModelScope.launch { + Log.i(TAG, "confirmSelection: ${_selected.value}") userPrefsDataStore.setContentLength(_selected.value) onDone() } diff --git a/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsViewModel.kt index bf98ceb..d0d7969 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsViewModel.kt @@ -1,5 +1,6 @@ package com.guidecity.app.ui.settings +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.guidecity.app.data.local.prefs.ContentLength @@ -11,6 +12,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "SettingsViewModel" + @HiltViewModel class SettingsViewModel @Inject constructor( private val userPrefsDataStore: UserPrefsDataStore, @@ -19,6 +22,7 @@ class SettingsViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) fun setContentLength(length: ContentLength) { + Log.i(TAG, "setContentLength: $length") viewModelScope.launch { userPrefsDataStore.setContentLength(length) } } } diff --git a/android/app/src/test/java/com/guidecity/app/MainDispatcherRule.kt b/android/app/src/test/java/com/guidecity/app/MainDispatcherRule.kt new file mode 100644 index 0000000..9d39d77 --- /dev/null +++ b/android/app/src/test/java/com/guidecity/app/MainDispatcherRule.kt @@ -0,0 +1,24 @@ +package com.guidecity.app + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** Swaps `Dispatchers.Main` for a test dispatcher so `viewModelScope` coroutines run synchronously. */ +@ExperimentalCoroutinesApi +class MainDispatcherRule( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/android/app/src/test/java/com/guidecity/app/data/local/prefs/ContentLengthTest.kt b/android/app/src/test/java/com/guidecity/app/data/local/prefs/ContentLengthTest.kt new file mode 100644 index 0000000..a6cafa3 --- /dev/null +++ b/android/app/src/test/java/com/guidecity/app/data/local/prefs/ContentLengthTest.kt @@ -0,0 +1,14 @@ +package com.guidecity.app.data.local.prefs + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ContentLengthTest { + + @Test + fun `toApiValue lowercases the enum name`() { + assertEquals("short", ContentLength.SHORT.toApiValue()) + assertEquals("medium", ContentLength.MEDIUM.toApiValue()) + assertEquals("long", ContentLength.LONG.toApiValue()) + } +} diff --git a/android/app/src/test/java/com/guidecity/app/ui/guide/GuideViewModelTest.kt b/android/app/src/test/java/com/guidecity/app/ui/guide/GuideViewModelTest.kt new file mode 100644 index 0000000..aca0ecc --- /dev/null +++ b/android/app/src/test/java/com/guidecity/app/ui/guide/GuideViewModelTest.kt @@ -0,0 +1,132 @@ +package com.guidecity.app.ui.guide + +import androidx.lifecycle.viewModelScope +import com.guidecity.app.MainDispatcherRule +import com.guidecity.app.data.local.prefs.ContentLength +import com.guidecity.app.data.local.prefs.UserPrefsDataStore +import com.guidecity.app.data.remote.dto.ContentDto +import com.guidecity.app.data.remote.dto.LatLonDto +import com.guidecity.app.data.remote.dto.NearbyPlaceDto +import com.guidecity.app.data.remote.dto.NearbyResponseDto +import com.guidecity.app.data.repository.PlacesRepository +import com.guidecity.app.location.LatLon +import com.guidecity.app.location.LocationProvider +import com.guidecity.app.location.SelectedLocationHolder +import com.guidecity.app.tts.TtsManager +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +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.Rule +import org.junit.Test +import java.io.IOException + +/** + * Covers the loadNearby() success/failure/retry paths — in particular a + * regression test for a real bug we hit: on a failed nearby-search call, the + * screen used to stay stuck on the loading spinner forever because the + * early-return path never cleared `isLoading`. See GuideViewModel.loadNearby. + */ +class GuideViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val testLocation = LatLon(lat = 55.75, lon = 37.62) + + private fun fakePlace(id: Int): NearbyPlaceDto = NearbyPlaceDto( + id = id, + slug = "place-$id", + category = "monument", + location = LatLonDto(lat = 55.75, lon = 37.62), + content = ContentDto(language = "ru", length = "medium", title = "Title $id", body = "Body $id"), + distanceM = 10.0 * id, + ) + + private fun buildViewModel( + placesRepository: PlacesRepository, + ttsManager: TtsManager = mockk(relaxed = true), + ): GuideViewModel { + val locationProvider = mockk(relaxed = true) + + val selectedLocationHolder = mockk() + every { selectedLocationHolder.location } returns MutableStateFlow(testLocation) + + val userPrefsDataStore = mockk() + every { userPrefsDataStore.contentLength } returns flowOf(ContentLength.MEDIUM) + + return GuideViewModel( + placesRepository = placesRepository, + locationProvider = locationProvider, + selectedLocationHolder = selectedLocationHolder, + userPrefsDataStore = userPrefsDataStore, + ttsManager = ttsManager, + ) + } + + @Test + fun `successful load populates places and starts narration`() = runTest { + val placesRepository = mockk() + val response = NearbyResponseDto(searchRadiusM = 300, count = 2, places = listOf(fakePlace(1), fakePlace(2))) + coEvery { + placesRepository.getNearby(lat = any(), lon = any(), citySlug = any(), lang = any(), length = any()) + } returns response + + val ttsManager = mockk(relaxed = true) + val viewModel = buildViewModel(placesRepository, ttsManager) + + val state = viewModel.uiState.value + assertEquals(false, state.isLoading) + assertEquals(2, state.places.size) + assertNull(state.errorMessage) + verify { ttsManager.speak(any(), any()) } + + viewModel.viewModelScope.cancel() + } + + @Test + fun `failed load clears the spinner and surfaces an error instead of hanging forever`() = runTest { + val placesRepository = mockk() + coEvery { + placesRepository.getNearby(lat = any(), lon = any(), citySlug = any(), lang = any(), length = any()) + } throws IOException("Cleartext HTTP traffic not permitted") + + val viewModel = buildViewModel(placesRepository) + + val state = viewModel.uiState.value + assertEquals(false, state.isLoading) + assertTrue(state.places.isEmpty()) + assertNotNull(state.errorMessage) + + viewModel.viewModelScope.cancel() + } + + @Test + fun `retry after a failure can succeed and clears the error`() = runTest { + val placesRepository = mockk() + val response = NearbyResponseDto(searchRadiusM = 300, count = 1, places = listOf(fakePlace(1))) + coEvery { + placesRepository.getNearby(lat = any(), lon = any(), citySlug = any(), lang = any(), length = any()) + } throws IOException("network down") andThen response + + val viewModel = buildViewModel(placesRepository) + assertNotNull(viewModel.uiState.value.errorMessage) + + viewModel.retry() + + val state = viewModel.uiState.value + assertEquals(false, state.isLoading) + assertNull(state.errorMessage) + assertEquals(1, state.places.size) + + viewModel.viewModelScope.cancel() + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 8b783f6..1c04b3a 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -19,6 +19,8 @@ ksp = "2.0.20-1.0.25" junit = "4.13.2" androidxTestExtJunit = "1.2.1" espressoCore = "3.6.1" +mockk = "1.13.12" +kotlinxCoroutinesTest = "1.8.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -57,6 +59,8 @@ androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }