Add logging throughout the Android app and unit tests
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 <noreply@anthropic.com>
This commit is contained in:
parent
be67902b45
commit
6e90956b3e
17 changed files with 323 additions and 18 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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})")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<List<FavoritePlaceEntity>> = favoritesRepository.observeFavorites()
|
||||
.onEach { Log.d(TAG, "favorites updated: ${it.size} item(s)") }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NearbyPlaceDto> = 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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<List<PlaceListItemDto>> = _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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ContentLength> = _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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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<LocationProvider>(relaxed = true)
|
||||
|
||||
val selectedLocationHolder = mockk<SelectedLocationHolder>()
|
||||
every { selectedLocationHolder.location } returns MutableStateFlow(testLocation)
|
||||
|
||||
val userPrefsDataStore = mockk<UserPrefsDataStore>()
|
||||
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<PlacesRepository>()
|
||||
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<TtsManager>(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<PlacesRepository>()
|
||||
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<PlacesRepository>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue