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" }