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
|
compose = true
|
||||||
buildConfig = 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 {
|
dependencies {
|
||||||
|
|
@ -107,6 +115,8 @@ dependencies {
|
||||||
// isolated integration point in the meantime.
|
// isolated integration point in the meantime.
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.mockk)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
package com.guidecity.app
|
package com.guidecity.app
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
private const val TAG = "GuideCityApp"
|
||||||
|
|
||||||
@HiltAndroidApp
|
@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
|
package com.guidecity.app
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
|
@ -8,10 +9,13 @@ import com.guidecity.app.navigation.GuideCityNavHost
|
||||||
import com.guidecity.app.theme.GuideCityTheme
|
import com.guidecity.app.theme.GuideCityTheme
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
|
private const val TAG = "MainActivity"
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
Log.d(TAG, "onCreate")
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
GuideCityTheme {
|
GuideCityTheme {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.guidecity.app.data.remote
|
package com.guidecity.app.data.remote
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import com.guidecity.app.BuildConfig
|
import com.guidecity.app.BuildConfig
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
|
@ -8,6 +9,8 @@ import okhttp3.logging.HttpLoggingInterceptor
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
|
||||||
|
private const val TAG = "OkHttp"
|
||||||
|
|
||||||
object RetrofitClient {
|
object RetrofitClient {
|
||||||
|
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
|
|
@ -18,7 +21,7 @@ object RetrofitClient {
|
||||||
private val okHttpClient: OkHttpClient by lazy {
|
private val okHttpClient: OkHttpClient by lazy {
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
.addInterceptor(
|
.addInterceptor(
|
||||||
HttpLoggingInterceptor().apply {
|
HttpLoggingInterceptor { message -> Log.d(TAG, message) }.apply {
|
||||||
level = if (BuildConfig.DEBUG) {
|
level = if (BuildConfig.DEBUG) {
|
||||||
HttpLoggingInterceptor.Level.BODY
|
HttpLoggingInterceptor.Level.BODY
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -30,6 +33,7 @@ object RetrofitClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
val apiService: ApiService by lazy {
|
val apiService: ApiService by lazy {
|
||||||
|
Log.i(TAG, "creating Retrofit client, baseUrl=${BuildConfig.API_BASE_URL}")
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl(BuildConfig.API_BASE_URL)
|
.baseUrl(BuildConfig.API_BASE_URL)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.google.android.gms.location.FusedLocationProviderClient
|
import com.google.android.gms.location.FusedLocationProviderClient
|
||||||
import com.google.android.gms.location.LocationServices
|
import com.google.android.gms.location.LocationServices
|
||||||
|
|
@ -13,6 +14,8 @@ import kotlinx.coroutines.tasks.await
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private const val TAG = "LocationProvider"
|
||||||
|
|
||||||
data class LatLon(val lat: Double, val lon: Double)
|
data class LatLon(val lat: Double, val lon: Double)
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|
@ -26,18 +29,34 @@ class LocationProvider @Inject constructor(
|
||||||
fun hasLocationPermission(): Boolean {
|
fun hasLocationPermission(): Boolean {
|
||||||
val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
|
val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_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. */
|
/** Returns null if permission isn't granted or no location could be resolved. */
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
suspend fun getCurrentLocation(): LatLon? {
|
suspend fun getCurrentLocation(): LatLon? {
|
||||||
if (!hasLocationPermission()) return null
|
if (!hasLocationPermission()) {
|
||||||
|
Log.w(TAG, "getCurrentLocation: permission not granted, returning null")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
val current = fusedClient
|
return try {
|
||||||
.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null)
|
val current = fusedClient
|
||||||
.await()
|
.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null)
|
||||||
val location = current ?: fusedClient.lastLocation.await()
|
.await()
|
||||||
return location?.let { LatLon(it.latitude, it.longitude) }
|
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.content.Context
|
||||||
import android.speech.tts.TextToSpeech
|
import android.speech.tts.TextToSpeech
|
||||||
import android.speech.tts.UtteranceProgressListener
|
import android.speech.tts.UtteranceProgressListener
|
||||||
|
import android.util.Log
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private const val TAG = "TtsManager"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps Android's built-in, on-device [TextToSpeech] engine — this satisfies
|
* Wraps Android's built-in, on-device [TextToSpeech] engine — this satisfies
|
||||||
* the app's "local voice narration" requirement without a custom ML model.
|
* 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 ->
|
private val tts: TextToSpeech = TextToSpeech(context) { status ->
|
||||||
isReady = status == TextToSpeech.SUCCESS
|
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
|
pendingUtterance = null
|
||||||
}.apply {
|
}.apply {
|
||||||
setOnUtteranceProgressListener(
|
setOnUtteranceProgressListener(
|
||||||
object : UtteranceProgressListener() {
|
object : UtteranceProgressListener() {
|
||||||
override fun onStart(utteranceId: String?) = Unit
|
override fun onStart(utteranceId: String?) {
|
||||||
|
Log.d(TAG, "utterance started: $utteranceId")
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDone(utteranceId: String?) {
|
override fun onDone(utteranceId: String?) {
|
||||||
|
Log.d(TAG, "utterance done: $utteranceId")
|
||||||
onDoneCallback?.invoke()
|
onDoneCallback?.invoke()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("Deprecated in Java, but still the callback the platform invokes")
|
@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) {
|
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. */
|
/** Speaks [text], calling [onDone] on the main thread once narration finishes. */
|
||||||
fun speak(text: String, onDone: (() -> Unit)? = null) {
|
fun speak(text: String, onDone: (() -> Unit)? = null) {
|
||||||
|
Log.d(TAG, "speak() called, muted=$isMuted ready=$isReady length=${text.length}")
|
||||||
lastSpokenText = text
|
lastSpokenText = text
|
||||||
onDoneCallback = onDone
|
onDoneCallback = onDone
|
||||||
if (isMuted) return
|
if (isMuted) return
|
||||||
|
|
@ -67,26 +85,33 @@ class TtsManager @Inject constructor(
|
||||||
|
|
||||||
private fun speakInternal(text: String, onDone: (() -> Unit)?) {
|
private fun speakInternal(text: String, onDone: (() -> Unit)?) {
|
||||||
onDoneCallback = onDone
|
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]. */
|
/** Stops narration and suppresses further [speak] calls until [unmute]. */
|
||||||
fun mute() {
|
fun mute() {
|
||||||
|
Log.d(TAG, "mute()")
|
||||||
isMuted = true
|
isMuted = true
|
||||||
tts.stop()
|
tts.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resumes narration from the start of the last spoken text. */
|
/** Resumes narration from the start of the last spoken text. */
|
||||||
fun unmute() {
|
fun unmute() {
|
||||||
|
Log.d(TAG, "unmute()")
|
||||||
isMuted = false
|
isMuted = false
|
||||||
lastSpokenText?.let { speakInternal(it, onDoneCallback) }
|
lastSpokenText?.let { speakInternal(it, onDoneCallback) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
Log.d(TAG, "stop()")
|
||||||
tts.stop()
|
tts.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun shutdown() {
|
fun shutdown() {
|
||||||
|
Log.d(TAG, "shutdown()")
|
||||||
tts.shutdown()
|
tts.shutdown()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.guidecity.app.ui.detail
|
package com.guidecity.app.ui.detail
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
|
@ -20,6 +21,8 @@ import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val TAG = "PlaceDetailViewModel"
|
||||||
|
|
||||||
data class PlaceDetailUiState(
|
data class PlaceDetailUiState(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val place: PlaceDetailDto? = null,
|
val place: PlaceDetailDto? = null,
|
||||||
|
|
@ -52,9 +55,13 @@ class PlaceDetailViewModel @Inject constructor(
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
Log.d(TAG, "init: placeId=$placeId")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val length = (userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM).toApiValue()
|
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)
|
_uiState.value = _uiState.value.copy(isLoading = false, place = place)
|
||||||
place?.let { ttsManager.speak(it.content.body) }
|
place?.let { ttsManager.speak(it.content.body) }
|
||||||
}
|
}
|
||||||
|
|
@ -62,11 +69,13 @@ class PlaceDetailViewModel @Inject constructor(
|
||||||
|
|
||||||
fun toggleMute() {
|
fun toggleMute() {
|
||||||
if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute()
|
if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute()
|
||||||
|
Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}")
|
||||||
_uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted)
|
_uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleFavorite() {
|
fun toggleFavorite() {
|
||||||
val place = _uiState.value.place ?: return
|
val place = _uiState.value.place ?: return
|
||||||
|
Log.d(TAG, "toggleFavorite: placeId=$placeId currentlyFavorite=${isFavorite.value}")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (isFavorite.value) {
|
if (isFavorite.value) {
|
||||||
favoritesRepository.removeFavorite(placeId)
|
favoritesRepository.removeFavorite(placeId)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.guidecity.app.ui.favorites
|
package com.guidecity.app.ui.favorites
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.guidecity.app.data.local.db.FavoritePlaceEntity
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val TAG = "FavoritesViewModel"
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class FavoritesViewModel @Inject constructor(
|
class FavoritesViewModel @Inject constructor(
|
||||||
favoritesRepository: FavoritesRepository,
|
favoritesRepository: FavoritesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val favorites: StateFlow<List<FavoritePlaceEntity>> = favoritesRepository.observeFavorites()
|
val favorites: StateFlow<List<FavoritePlaceEntity>> = favoritesRepository.observeFavorites()
|
||||||
|
.onEach { Log.d(TAG, "favorites updated: ${it.size} item(s)") }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.guidecity.app.ui.guide
|
package com.guidecity.app.ui.guide
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.guidecity.app.data.local.prefs.ContentLength
|
import com.guidecity.app.data.local.prefs.ContentLength
|
||||||
|
|
@ -20,6 +21,8 @@ import kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val TAG = "GuideViewModel"
|
||||||
|
|
||||||
data class GuideUiState(
|
data class GuideUiState(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val places: List<NearbyPlaceDto> = emptyList(),
|
val places: List<NearbyPlaceDto> = emptyList(),
|
||||||
|
|
@ -54,6 +57,7 @@ class GuideViewModel @Inject constructor(
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
contentLength = userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM
|
contentLength = userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM
|
||||||
|
Log.i(TAG, "init: contentLength=$contentLength")
|
||||||
ttsManager.setLanguage(Locale("ru"))
|
ttsManager.setLanguage(Locale("ru"))
|
||||||
loadNearby(startNarration = true)
|
loadNearby(startNarration = true)
|
||||||
startRescanLoop()
|
startRescanLoop()
|
||||||
|
|
@ -63,9 +67,11 @@ class GuideViewModel @Inject constructor(
|
||||||
private fun startRescanLoop() {
|
private fun startRescanLoop() {
|
||||||
if (rescanStarted) return
|
if (rescanStarted) return
|
||||||
rescanStarted = true
|
rescanStarted = true
|
||||||
|
Log.d(TAG, "starting 60s re-scan loop")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(60_000)
|
delay(60_000)
|
||||||
|
Log.d(TAG, "re-scan tick")
|
||||||
loadNearby(startNarration = false)
|
loadNearby(startNarration = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,10 +80,13 @@ class GuideViewModel @Inject constructor(
|
||||||
private suspend fun loadNearby(startNarration: Boolean) {
|
private suspend fun loadNearby(startNarration: Boolean) {
|
||||||
val location = selectedLocationHolder.location.value ?: locationProvider.getCurrentLocation()
|
val location = selectedLocationHolder.location.value ?: locationProvider.getCurrentLocation()
|
||||||
if (location == null) {
|
if (location == null) {
|
||||||
|
Log.w(TAG, "loadNearby: no location available")
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "Location unavailable")
|
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "Location unavailable")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "loadNearby: lat=${location.lat} lon=${location.lon} length=${contentLength.toApiValue()}")
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
placesRepository.getNearby(
|
placesRepository.getNearby(
|
||||||
lat = location.lat,
|
lat = location.lat,
|
||||||
|
|
@ -87,6 +96,7 @@ class GuideViewModel @Inject constructor(
|
||||||
length = contentLength.toApiValue(),
|
length = contentLength.toApiValue(),
|
||||||
)
|
)
|
||||||
}.onSuccess { response ->
|
}.onSuccess { response ->
|
||||||
|
Log.i(TAG, "loadNearby success: ${response.places.size} place(s), radius=${response.searchRadiusM}m")
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
places = response.places,
|
places = response.places,
|
||||||
|
|
@ -98,6 +108,7 @@ class GuideViewModel @Inject constructor(
|
||||||
narrateActive()
|
narrateActive()
|
||||||
}
|
}
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
|
Log.e(TAG, "loadNearby failed", error)
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = error.message ?: "Network error",
|
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). */
|
/** Retries after a failed load (e.g. tap a "retry" button shown on error). */
|
||||||
fun retry() {
|
fun retry() {
|
||||||
|
Log.d(TAG, "retry() called")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
loadNearby(startNarration = true)
|
loadNearby(startNarration = true)
|
||||||
|
|
@ -116,13 +128,16 @@ class GuideViewModel @Inject constructor(
|
||||||
private fun narrateActive() {
|
private fun narrateActive() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
val place = state.places.getOrNull(state.activeIndex) ?: return
|
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() }
|
ttsManager.speak(place.content.body) { onNarrationDone() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onNarrationDone() {
|
private fun onNarrationDone() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
val nextIndex = state.activeIndex + 1
|
val nextIndex = state.activeIndex + 1
|
||||||
|
Log.d(TAG, "onNarrationDone: finished index=${state.activeIndex}, nextIndex=$nextIndex")
|
||||||
if (nextIndex >= state.places.size) {
|
if (nextIndex >= state.places.size) {
|
||||||
|
Log.i(TAG, "onNarrationDone: reached end of nearby list")
|
||||||
_uiState.value = state.copy(doneIndices = state.doneIndices + state.activeIndex)
|
_uiState.value = state.copy(doneIndices = state.doneIndices + state.activeIndex)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -137,17 +152,20 @@ class GuideViewModel @Inject constructor(
|
||||||
fun setActiveIndex(index: Int) {
|
fun setActiveIndex(index: Int) {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
if (index !in state.places.indices || index == state.activeIndex) return
|
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)
|
_uiState.value = state.copy(activeIndex = index)
|
||||||
narrateActive()
|
narrateActive()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleMute() {
|
fun toggleMute() {
|
||||||
if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute()
|
if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute()
|
||||||
|
Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}")
|
||||||
_uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted)
|
_uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
|
Log.d(TAG, "onCleared")
|
||||||
ttsManager.stop()
|
ttsManager.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.guidecity.app.ui.map
|
package com.guidecity.app.ui.map
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.guidecity.app.data.remote.dto.PlaceListItemDto
|
import com.guidecity.app.data.remote.dto.PlaceListItemDto
|
||||||
|
|
@ -14,6 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val TAG = "MapViewModel"
|
||||||
|
|
||||||
sealed interface MapUiState {
|
sealed interface MapUiState {
|
||||||
data object CheckingPermission : MapUiState
|
data object CheckingPermission : MapUiState
|
||||||
data object NeedsPermission : MapUiState
|
data object NeedsPermission : MapUiState
|
||||||
|
|
@ -35,7 +38,9 @@ class MapViewModel @Inject constructor(
|
||||||
val searchResults: StateFlow<List<PlaceListItemDto>> = _searchResults.asStateFlow()
|
val searchResults: StateFlow<List<PlaceListItemDto>> = _searchResults.asStateFlow()
|
||||||
|
|
||||||
fun checkInitialPermission(onLocationResolved: () -> Unit) {
|
fun checkInitialPermission(onLocationResolved: () -> Unit) {
|
||||||
if (locationProvider.hasLocationPermission()) {
|
val hasPermission = locationProvider.hasLocationPermission()
|
||||||
|
Log.d(TAG, "checkInitialPermission: hasPermission=$hasPermission")
|
||||||
|
if (hasPermission) {
|
||||||
resolveLocation(onLocationResolved)
|
resolveLocation(onLocationResolved)
|
||||||
} else {
|
} else {
|
||||||
_uiState.value = MapUiState.NeedsPermission
|
_uiState.value = MapUiState.NeedsPermission
|
||||||
|
|
@ -43,6 +48,7 @@ class MapViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onPermissionResult(granted: Boolean, onLocationResolved: () -> Unit) {
|
fun onPermissionResult(granted: Boolean, onLocationResolved: () -> Unit) {
|
||||||
|
Log.i(TAG, "onPermissionResult: granted=$granted")
|
||||||
if (granted) {
|
if (granted) {
|
||||||
resolveLocation(onLocationResolved)
|
resolveLocation(onLocationResolved)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -55,9 +61,11 @@ class MapViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val location = locationProvider.getCurrentLocation()
|
val location = locationProvider.getCurrentLocation()
|
||||||
if (location != null) {
|
if (location != null) {
|
||||||
|
Log.i(TAG, "resolveLocation: got lat=${location.lat} lon=${location.lon}")
|
||||||
selectedLocationHolder.set(location)
|
selectedLocationHolder.set(location)
|
||||||
onLocationResolved()
|
onLocationResolved()
|
||||||
} else {
|
} else {
|
||||||
|
Log.w(TAG, "resolveLocation: location unavailable, falling back to search")
|
||||||
_uiState.value = MapUiState.SearchFallback
|
_uiState.value = MapUiState.SearchFallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -68,14 +76,22 @@ class MapViewModel @Inject constructor(
|
||||||
_searchResults.value = emptyList()
|
_searchResults.value = emptyList()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "searchPlaces: query=$query")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_searchResults.value = runCatching {
|
runCatching {
|
||||||
placesRepository.searchPlaces(query, citySlug = "moscow", lang = "ru")
|
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) {
|
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))
|
selectedLocationHolder.set(LatLon(place.location.lat, place.location.lon))
|
||||||
onLocationResolved()
|
onLocationResolved()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.guidecity.app.ui.onboarding
|
package com.guidecity.app.ui.onboarding
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.guidecity.app.data.local.prefs.UserPrefsDataStore
|
import com.guidecity.app.data.local.prefs.UserPrefsDataStore
|
||||||
|
|
@ -7,12 +8,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val TAG = "OnboardingViewModel"
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class OnboardingViewModel @Inject constructor(
|
class OnboardingViewModel @Inject constructor(
|
||||||
private val userPrefsDataStore: UserPrefsDataStore,
|
private val userPrefsDataStore: UserPrefsDataStore,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
fun markOnboardingSeen(onDone: () -> Unit) {
|
fun markOnboardingSeen(onDone: () -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
Log.i(TAG, "markOnboardingSeen")
|
||||||
userPrefsDataStore.setOnboardingSeen(true)
|
userPrefsDataStore.setOnboardingSeen(true)
|
||||||
onDone()
|
onDone()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.guidecity.app.ui.preference
|
package com.guidecity.app.ui.preference
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.guidecity.app.data.local.prefs.ContentLength
|
import com.guidecity.app.data.local.prefs.ContentLength
|
||||||
|
|
@ -11,6 +12,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val TAG = "ContentLengthViewModel"
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ContentLengthViewModel @Inject constructor(
|
class ContentLengthViewModel @Inject constructor(
|
||||||
private val userPrefsDataStore: UserPrefsDataStore,
|
private val userPrefsDataStore: UserPrefsDataStore,
|
||||||
|
|
@ -20,11 +23,13 @@ class ContentLengthViewModel @Inject constructor(
|
||||||
val selected: StateFlow<ContentLength> = _selected.asStateFlow()
|
val selected: StateFlow<ContentLength> = _selected.asStateFlow()
|
||||||
|
|
||||||
fun select(length: ContentLength) {
|
fun select(length: ContentLength) {
|
||||||
|
Log.d(TAG, "select: $length")
|
||||||
_selected.value = length
|
_selected.value = length
|
||||||
}
|
}
|
||||||
|
|
||||||
fun confirmSelection(onDone: () -> Unit) {
|
fun confirmSelection(onDone: () -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
Log.i(TAG, "confirmSelection: ${_selected.value}")
|
||||||
userPrefsDataStore.setContentLength(_selected.value)
|
userPrefsDataStore.setContentLength(_selected.value)
|
||||||
onDone()
|
onDone()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.guidecity.app.ui.settings
|
package com.guidecity.app.ui.settings
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.guidecity.app.data.local.prefs.ContentLength
|
import com.guidecity.app.data.local.prefs.ContentLength
|
||||||
|
|
@ -11,6 +12,8 @@ import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val TAG = "SettingsViewModel"
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val userPrefsDataStore: UserPrefsDataStore,
|
private val userPrefsDataStore: UserPrefsDataStore,
|
||||||
|
|
@ -19,6 +22,7 @@ class SettingsViewModel @Inject constructor(
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
fun setContentLength(length: ContentLength) {
|
fun setContentLength(length: ContentLength) {
|
||||||
|
Log.i(TAG, "setContentLength: $length")
|
||||||
viewModelScope.launch { userPrefsDataStore.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"
|
junit = "4.13.2"
|
||||||
androidxTestExtJunit = "1.2.1"
|
androidxTestExtJunit = "1.2.1"
|
||||||
espressoCore = "3.6.1"
|
espressoCore = "3.6.1"
|
||||||
|
mockk = "1.13.12"
|
||||||
|
kotlinxCoroutinesTest = "1.8.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-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-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue