Persist listened places, fix restart-on-reopen bug, add stats/reset

Five behavior changes from live testing feedback:

1. Narration no longer leads with the place title — the body text
   already opens with the name, so it was read twice.

2. "Done" is now derived from a persisted set of listened place IDs
   (UserPrefsDataStore.listenedPlaceIds), not an in-memory index set
   that got wiped on every load. A place that's ever been narrated to
   completion — in the guide list or the detail screen — is never
   auto-narrated again, including across the 60s re-scan and app
   restarts. Auto-advance now skips straight to the next unlistened
   place instead of walking sequentially. Settings has a new "reset
   listened places" button.

3. TtsManager is now keyed by place id: calling speak() for the place
   that's already playing just re-attaches the onDone callback instead
   of restarting via QUEUE_FLUSH. Fixes opening a place's detail
   screen while the guide list is already narrating it restarting
   playback from the beginning. Marking a place "listened" now also
   lives in TtsManager itself (on natural onDone, not onError/onStop),
   so it's correct regardless of which screen was driving playback.

4. Settings now shows "Listened: X of Y (Z%)" against the total place
   count for the city.

5. Search radius changed from 10km to 2km (client default in
   PlacesRepository/MapViewModel, and the backend's own default for
   consistency) — but the result count is uncapped, same as before;
   every place within the radius is returned regardless of how many
   that is.

Caught a real bug while testing the "skip listened" change: the new
listenedPlaceIds collector ran in a separate coroutine that hadn't
necessarily delivered its first value before the initial loadNearby()
call, so freshly-loaded listened state could be missed on cold start.
Fixed by awaiting listenedPlaceIds.first() synchronously before the
first load, with a separate .drop(1) collector for later changes
(e.g. the reset button). Also hardened GuideViewModelTest with
try/finally around viewModelScope.cancel() — a failing assertion was
skipping cleanup and turning into a 5+ minute hang instead of a fast
failure, since the 60s re-scan loop was never cancelled.

Verified: testDebugUnitTest passes (4/4), assembleDebug produces a
working APK, sent to Telegram. Backend pytest still passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-07-10 07:44:12 +00:00
parent f6735db46f
commit cd428d0fb7
13 changed files with 254 additions and 56 deletions

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
@ -20,6 +21,7 @@ class UserPrefsDataStore @Inject constructor(
private object Keys {
val ONBOARDING_SEEN = booleanPreferencesKey("onboarding_seen")
val CONTENT_LENGTH = stringPreferencesKey("content_length_pref")
val LISTENED_PLACE_IDS = stringSetPreferencesKey("listened_place_ids")
}
val onboardingSeen: Flow<Boolean> =
@ -38,4 +40,26 @@ class UserPrefsDataStore @Inject constructor(
suspend fun setContentLength(length: ContentLength) {
context.dataStore.edit { it[Keys.CONTENT_LENGTH] = length.name }
}
/**
* Places that have already been narrated to completion persisted so a
* card is never auto-read twice, even across the 60s re-scan or an app
* restart. See [markPlaceListened] / [resetListenedPlaces].
*/
val listenedPlaceIds: Flow<Set<Int>> =
context.dataStore.data.map { prefs ->
(prefs[Keys.LISTENED_PLACE_IDS] ?: emptySet()).mapNotNull { it.toIntOrNull() }.toSet()
}
suspend fun markPlaceListened(placeId: Int) {
context.dataStore.edit { prefs ->
val current = prefs[Keys.LISTENED_PLACE_IDS] ?: emptySet()
prefs[Keys.LISTENED_PLACE_IDS] = current + placeId.toString()
}
}
/** Settings screen "reset" button — forgets all listened places so they get read again. */
suspend fun resetListenedPlaces() {
context.dataStore.edit { prefs -> prefs[Keys.LISTENED_PLACE_IDS] = emptySet() }
}
}

View file

@ -20,9 +20,14 @@ class PlacesRepository @Inject constructor(
suspend fun searchPlaces(query: String, citySlug: String?, lang: String): List<PlaceListItemDto> =
apiService.searchPlaces(query, citySlug, lang)
/** Full place listing for a city — used for "listened X of Y" stats, not paginated. */
suspend fun getCityPlaces(citySlug: String, lang: String): List<PlaceListItemDto> =
apiService.getCityPlaces(citySlug = citySlug, lang = lang)
/**
* Fetches nearby places sorted by distance, iteratively expanding the
* search radius server-side until at least [minResults] are found.
* Every match within the radius is returned there's no cap on count.
*
* `speed`/`heading`-aware filtering is a reserved v2 extension on the
* backend (see nearby_search.py); this client doesn't send them yet.
@ -34,7 +39,7 @@ class PlacesRepository @Inject constructor(
lang: String,
length: String,
minResults: Int = 5,
maxRadiusM: Int = 10_000,
maxRadiusM: Int = 2_000,
): NearbyResponseDto = apiService.getNearby(
lat = lat,
lon = lon,

View file

@ -4,7 +4,13 @@ import android.content.Context
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import com.guidecity.app.data.local.prefs.UserPrefsDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.Locale
import java.util.UUID
import javax.inject.Inject
@ -17,18 +23,30 @@ private const val TAG = "TtsManager"
* the app's "local voice narration" requirement without a custom ML model.
*
* A single app-wide instance is shared by the guide screen and the place
* detail screen (both injected via Hilt) so only one narration plays at a
* time; calling [speak] naturally interrupts whatever was playing before via
* [TextToSpeech.QUEUE_FLUSH].
* detail screen (both injected via Hilt), keyed by place id:
* - calling [speak] for a *different* key naturally interrupts whatever was
* playing before via [TextToSpeech.QUEUE_FLUSH].
* - calling [speak] for the place that's *already* playing does NOT
* restart it e.g. opening a place's detail screen while the guide list
* is already narrating it just re-attaches [onDone], it doesn't reset
* playback to the beginning.
* - when an utterance finishes naturally (not interrupted/muted), the
* place is persisted as listened via [UserPrefsDataStore], so it's never
* auto-narrated again regardless of which screen was driving playback.
*/
@Singleton
class TtsManager @Inject constructor(
@ApplicationContext context: Context,
private val userPrefsDataStore: UserPrefsDataStore,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var isReady = false
private var pendingUtterance: Pair<String, (() -> Unit)?>? = null
private var onDoneCallback: (() -> Unit)? = null
private var lastSpokenText: String? = null
private var currentPlaceId: Int? = null
private var isCurrentlySpeaking = false
var isMuted: Boolean = false
private set
@ -50,16 +68,22 @@ class TtsManager @Inject constructor(
object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
Log.d(TAG, "utterance started: $utteranceId")
isCurrentlySpeaking = true
}
override fun onDone(utteranceId: String?) {
Log.d(TAG, "utterance done: $utteranceId")
isCurrentlySpeaking = false
currentPlaceId?.let { placeId ->
scope.launch { userPrefsDataStore.markPlaceListened(placeId) }
}
onDoneCallback?.invoke()
}
@Deprecated("Deprecated in Java, but still the callback the platform invokes")
override fun onError(utteranceId: String?) {
Log.e(TAG, "utterance error: $utteranceId")
isCurrentlySpeaking = false
}
},
)
@ -70,9 +94,20 @@ class TtsManager @Inject constructor(
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}")
/**
* Speaks [text] for [placeId], calling [onDone] once narration finishes.
* If [placeId] is already the one currently playing, this just
* re-attaches [onDone] without restarting playback.
*/
fun speak(placeId: Int, text: String, onDone: (() -> Unit)? = null) {
if (placeId == currentPlaceId && isCurrentlySpeaking) {
Log.d(TAG, "speak(): placeId=$placeId already playing, attaching onDone without restart")
onDoneCallback = onDone
return
}
Log.d(TAG, "speak() called for placeId=$placeId, muted=$isMuted ready=$isReady length=${text.length}")
currentPlaceId = placeId
lastSpokenText = text
onDoneCallback = onDone
if (isMuted) return
@ -113,5 +148,6 @@ class TtsManager @Inject constructor(
fun shutdown() {
Log.d(TAG, "shutdown()")
tts.shutdown()
scope.cancel()
}
}

View file

@ -40,7 +40,7 @@ data class PlaceDetailUiState(
* Shows one place's full content with narration, and when the requested
* place is part of the user's current nearby list lets them swipe to the
* other nearby places too, reusing the same ordering, active/narration
* progression, and 10km search cap as the guide list screen. When reached
* progression, and 2km search cap as the guide list screen. When reached
* from Favorites/search for a place that isn't in that list, it falls back
* to a single, non-swipeable place fetched directly by id.
*/
@ -80,7 +80,6 @@ class PlaceDetailViewModel @Inject constructor(
lang = "ru",
length = length,
minResults = 5,
maxRadiusM = 10_000,
)
}.onFailure { Log.e(TAG, "getNearby failed", it) }.getOrNull()
}
@ -140,8 +139,10 @@ class PlaceDetailViewModel @Inject constructor(
private fun narrateActive() {
val state = _uiState.value
val place = state.places.getOrNull(state.activeIndex) ?: return
val text = buildNarrationText(place.content.title, place.distanceM, place.content.body)
ttsManager.speak(text) { onNarrationDone() }
// If this exact place is already playing (e.g. opened while the guide
// list is narrating it), TtsManager won't restart it from scratch.
val text = buildNarrationText(place.distanceM, place.content.body)
ttsManager.speak(place.id, text) { onNarrationDone() }
}
private fun onNarrationDone() {

View file

@ -16,6 +16,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@ -39,6 +40,11 @@ data class GuideUiState(
* re-sorts by fresh distance (TODO(v2): factor in the user's speed/heading
* once the backend's reserved `speed`/`heading` nearby params are used, to
* auto-pick a shorter content length when moving fast through a dense area).
*
* "Done" is derived from [UserPrefsDataStore.listenedPlaceIds] a place
* that was ever narrated to completion (in this screen or the detail
* screen) is never auto-narrated again, even across re-scans or an app
* restart, until the user resets it from Settings.
*/
@HiltViewModel
class GuideViewModel @Inject constructor(
@ -54,15 +60,35 @@ class GuideViewModel @Inject constructor(
private var contentLength: ContentLength = ContentLength.MEDIUM
private var rescanStarted = false
private var listenedIds: Set<Int> = emptySet()
init {
viewModelScope.launch {
contentLength = userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM
Log.i(TAG, "init: contentLength=$contentLength")
// Must be populated before the first loadNearby(), so places already
// listened in a previous session are correctly skipped from the start.
listenedIds = userPrefsDataStore.listenedPlaceIds.first()
Log.i(TAG, "init: contentLength=$contentLength, ${listenedIds.size} place(s) already listened")
ttsManager.setLanguage(Locale("ru"))
loadNearby(startNarration = true)
startRescanLoop()
}
// React to *later* changes only (e.g. the Settings "reset" button) —
// the initial value was already consumed above via .first().
viewModelScope.launch {
userPrefsDataStore.listenedPlaceIds.drop(1).collect { ids ->
Log.d(TAG, "listenedPlaceIds changed: ${ids.size} total")
listenedIds = ids
recomputeDoneIndices()
}
}
}
private fun recomputeDoneIndices() {
val state = _uiState.value
if (state.places.isEmpty()) return
val doneIdx = state.places.indices.filter { state.places[it].id in listenedIds }.toSet()
_uiState.value = state.copy(doneIndices = doneIdx)
}
private fun startRescanLoop() {
@ -98,14 +124,17 @@ class GuideViewModel @Inject constructor(
)
}.onSuccess { response ->
Log.i(TAG, "loadNearby success: ${response.places.size} place(s), radius=${response.searchRadiusM}m")
val places = response.places
val doneIdx = places.indices.filter { places[it].id in listenedIds }.toSet()
val firstUnlistened = places.indices.firstOrNull { it !in doneIdx } ?: 0
_uiState.value = _uiState.value.copy(
isLoading = false,
places = response.places,
activeIndex = 0,
doneIndices = emptySet(),
places = places,
activeIndex = firstUnlistened,
doneIndices = doneIdx,
errorMessage = null,
)
if (startNarration) {
if (startNarration && firstUnlistened !in doneIdx) {
narrateActive()
}
}.onFailure { error ->
@ -130,23 +159,22 @@ class GuideViewModel @Inject constructor(
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}")
val text = buildNarrationText(place.content.title, place.distanceM, place.content.body)
ttsManager.speak(text) { onNarrationDone() }
val text = buildNarrationText(place.distanceM, place.content.body)
ttsManager.speak(place.id, text) { onNarrationDone() }
}
/** Advances to the next place that hasn't been listened to yet, skipping any already-done ones. */
private fun onNarrationDone() {
val state = _uiState.value
val nextIndex = state.activeIndex + 1
val newDone = state.doneIndices + state.activeIndex
val nextIndex = state.places.indices.firstOrNull { it !in newDone }
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)
if (nextIndex == null) {
Log.i(TAG, "onNarrationDone: no more unlistened places")
_uiState.value = state.copy(doneIndices = newDone)
return
}
_uiState.value = state.copy(
doneIndices = state.doneIndices + state.activeIndex,
activeIndex = nextIndex,
)
_uiState.value = state.copy(doneIndices = newDone, activeIndex = nextIndex)
narrateActive()
}

View file

@ -17,7 +17,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
private const val TAG = "MapViewModel"
private const val MAX_SEARCH_RADIUS_M = 10_000
private const val MAX_SEARCH_RADIUS_M = 2_000
sealed interface MapUiState {
data object CheckingPermission : MapUiState

View file

@ -2,12 +2,16 @@ package com.guidecity.app.ui.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -32,6 +36,7 @@ fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
) {
val selected by viewModel.contentLength.collectAsState()
val stats by viewModel.listenedStats.collectAsState()
Scaffold(
topBar = {
@ -71,6 +76,28 @@ fun SettingsScreen(
isSelected = selected == ContentLength.LONG,
onSelect = { viewModel.setContentLength(ContentLength.LONG) },
)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.settings_stats_title),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(
R.string.settings_stats_progress,
stats.listened,
stats.total,
stats.percent,
),
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.resetListenedPlaces() }) {
Text(stringResource(R.string.settings_reset_listened))
}
}
}
}

View file

@ -5,24 +5,54 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.guidecity.app.data.local.prefs.ContentLength
import com.guidecity.app.data.local.prefs.UserPrefsDataStore
import com.guidecity.app.data.repository.PlacesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val TAG = "SettingsViewModel"
data class ListenedStats(val listened: Int, val total: Int) {
val percent: Int get() = if (total == 0) 0 else (listened * 100) / total
}
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val userPrefsDataStore: UserPrefsDataStore,
private val placesRepository: PlacesRepository,
) : ViewModel() {
val contentLength: StateFlow<ContentLength?> = userPrefsDataStore.contentLength
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _totalPlaces = MutableStateFlow(0)
val listenedStats: StateFlow<ListenedStats> = combine(
userPrefsDataStore.listenedPlaceIds,
_totalPlaces,
) { listenedIds, total -> ListenedStats(listened = listenedIds.size, total = total) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListenedStats(0, 0))
init {
viewModelScope.launch {
runCatching { placesRepository.getCityPlaces(citySlug = "moscow", lang = "ru") }
.onSuccess { _totalPlaces.value = it.size }
.onFailure { Log.e(TAG, "failed to load total place count", it) }
}
}
fun setContentLength(length: ContentLength) {
Log.i(TAG, "setContentLength: $length")
viewModelScope.launch { userPrefsDataStore.setContentLength(length) }
}
fun resetListenedPlaces() {
Log.i(TAG, "resetListenedPlaces")
viewModelScope.launch { userPrefsDataStore.resetListenedPlaces() }
}
}

View file

@ -11,13 +11,15 @@ fun formatDistanceForNarration(distanceM: Double): String =
}
/**
* Prefixes the narration body with the place's name and its distance from
* the user. Pass a negative [distanceM] (no real distance known e.g. a
* Builds the narration body prefixed with distance from the user. The
* place's title is deliberately NOT included the body text itself
* already opens with the name in practice, so reading it twice was
* redundant. Pass a negative [distanceM] (no real distance known e.g. a
* place opened standalone from Favorites/search) to omit the distance clause.
*/
fun buildNarrationText(title: String, distanceM: Double, body: String): String =
fun buildNarrationText(distanceM: Double, body: String): String =
if (distanceM < 0) {
"$title. $body"
body
} else {
"$title. Расстояние: ${formatDistanceForNarration(distanceM)}. $body"
"Расстояние: ${formatDistanceForNarration(distanceM)}. $body"
}

View file

@ -18,7 +18,7 @@
<string name="map_checking_nearby">Ищем места поблизости…</string>
<string name="map_places_found">Найдено мест поблизости: %1$d (в радиусе %2$d м)</string>
<string name="map_start">Начать</string>
<string name="map_no_places_nearby">В радиусе 10 км ничего не нашлось.</string>
<string name="map_no_places_nearby">В радиусе 2 км ничего не нашлось.</string>
<string name="map_show_list">Показать список</string>
<string name="map_show_map">Показать карту</string>
@ -35,6 +35,9 @@
<string name="settings_title">Настройки</string>
<string name="settings_content_length_label">Подробность рассказа</string>
<string name="settings_stats_title">Прогресс прослушивания</string>
<string name="settings_stats_progress">Прослушано: %1$d из %2$d (%3$d%%)</string>
<string name="settings_reset_listened">Сбросить прослушанные места</string>
<string name="place_not_found">Место не найдено.</string>
</resources>

View file

@ -18,7 +18,7 @@
<string name="map_checking_nearby">Looking for places nearby…</string>
<string name="map_places_found">Found %1$d place(s) nearby (within %2$d m)</string>
<string name="map_start">Start</string>
<string name="map_no_places_nearby">Nothing found within 10 km of here.</string>
<string name="map_no_places_nearby">Nothing found within 2 km of here.</string>
<string name="map_show_list">Show list</string>
<string name="map_show_map">Show map</string>
@ -35,6 +35,9 @@
<string name="settings_title">Settings</string>
<string name="settings_content_length_label">Content length</string>
<string name="settings_stats_title">Listening progress</string>
<string name="settings_stats_progress">Listened: %1$d of %2$d (%3$d%%)</string>
<string name="settings_reset_listened">Reset listened places</string>
<string name="place_not_found">Place not found.</string>
</resources>

View file

@ -34,6 +34,13 @@ import java.io.IOException
* 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.
*
* Every test cancels the ViewModel's scope in a `finally` block. GuideViewModel
* starts an infinite 60s re-scan loop (`while (isActive) { delay(60_000); ... }`);
* under UnconfinedTestDispatcher, runTest's implicit final drain will hang
* forever trying to advance that loop unless it's cancelled first and if an
* assertion throws before an un-guarded cancel() call, you get a 5+ minute
* test hang instead of a clean failure. Learned this the hard way once.
*/
class GuideViewModelTest {
@ -54,6 +61,7 @@ class GuideViewModelTest {
private fun buildViewModel(
placesRepository: PlacesRepository,
ttsManager: TtsManager = mockk(relaxed = true),
listenedIds: Set<Int> = emptySet(),
): GuideViewModel {
val locationProvider = mockk<LocationProvider>(relaxed = true)
@ -62,6 +70,7 @@ class GuideViewModelTest {
val userPrefsDataStore = mockk<UserPrefsDataStore>()
every { userPrefsDataStore.contentLength } returns flowOf(ContentLength.MEDIUM)
every { userPrefsDataStore.listenedPlaceIds } returns flowOf(listenedIds)
return GuideViewModel(
placesRepository = placesRepository,
@ -82,14 +91,15 @@ class GuideViewModelTest {
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()
try {
val state = viewModel.uiState.value
assertEquals(false, state.isLoading)
assertEquals(2, state.places.size)
assertNull(state.errorMessage)
verify { ttsManager.speak(any(), any(), any()) }
} finally {
viewModel.viewModelScope.cancel()
}
}
@Test
@ -100,13 +110,40 @@ class GuideViewModelTest {
} throws IOException("Cleartext HTTP traffic not permitted")
val viewModel = buildViewModel(placesRepository)
try {
val state = viewModel.uiState.value
assertEquals(false, state.isLoading)
assertTrue(state.places.isEmpty())
assertNotNull(state.errorMessage)
} finally {
viewModel.viewModelScope.cancel()
}
}
val state = viewModel.uiState.value
assertEquals(false, state.isLoading)
assertTrue(state.places.isEmpty())
assertNotNull(state.errorMessage)
@Test
fun `places already listened are marked done and skipped, not re-narrated`() = runTest {
val placesRepository = mockk<PlacesRepository>()
val response = NearbyResponseDto(
searchRadiusM = 300,
count = 3,
places = listOf(fakePlace(1), fakePlace(2), fakePlace(3)),
)
coEvery {
placesRepository.getNearby(lat = any(), lon = any(), citySlug = any(), lang = any(), length = any())
} returns response
viewModel.viewModelScope.cancel()
val ttsManager = mockk<TtsManager>(relaxed = true)
// place id 1 (index 0) was already listened in a previous session
val viewModel = buildViewModel(placesRepository, ttsManager, listenedIds = setOf(1))
try {
val state = viewModel.uiState.value
assertTrue(0 in state.doneIndices)
assertEquals(1, state.activeIndex)
verify(exactly = 0) { ttsManager.speak(1, any(), any()) }
verify { ttsManager.speak(2, any(), any()) }
} finally {
viewModel.viewModelScope.cancel()
}
}
@Test
@ -118,15 +155,17 @@ class GuideViewModelTest {
} throws IOException("network down") andThen response
val viewModel = buildViewModel(placesRepository)
assertNotNull(viewModel.uiState.value.errorMessage)
try {
assertNotNull(viewModel.uiState.value.errorMessage)
viewModel.retry()
viewModel.retry()
val state = viewModel.uiState.value
assertEquals(false, state.isLoading)
assertNull(state.errorMessage)
assertEquals(1, state.places.size)
viewModel.viewModelScope.cancel()
val state = viewModel.uiState.value
assertEquals(false, state.isLoading)
assertNull(state.errorMessage)
assertEquals(1, state.places.size)
} finally {
viewModel.viewModelScope.cancel()
}
}
}

View file

@ -25,7 +25,7 @@ def nearby(
min_results: int = Query(default=5, ge=1, le=50),
initial_radius_m: int = Query(default=300, ge=50, le=5000),
step_m: int = Query(default=200, ge=50, le=5000),
max_radius_m: int = Query(default=5000, ge=100, le=20000),
max_radius_m: int = Query(default=2000, ge=100, le=20000),
speed: float | None = Query(
default=None,
description="Reserved for v2: user's speed in m/s. Currently accepted but ignored.",