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:
vrubelroman 2026-07-09 20:10:31 +00:00
parent be67902b45
commit 6e90956b3e
17 changed files with 323 additions and 18 deletions

View file

@ -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))

View file

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

View file

@ -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 {

View file

@ -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)

View file

@ -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
}
return try {
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) }
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
}
}
}

View file

@ -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()
}
}

View file

@ -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)

View file

@ -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())
}

View file

@ -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()
}
}

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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) }
}
}

View file

@ -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()
}
}

View file

@ -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())
}
}

View file

@ -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()
}
}

View file

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