diff --git a/app/src/main/java/org/nitri/opentopo/MapFragment.kt b/app/src/main/java/org/nitri/opentopo/MapFragment.kt index 187cf530..9925f3df 100644 --- a/app/src/main/java/org/nitri/opentopo/MapFragment.kt +++ b/app/src/main/java/org/nitri/opentopo/MapFragment.kt @@ -54,6 +54,9 @@ import org.nitri.opentopo.overlay.GestureOverlay import org.nitri.opentopo.overlay.GestureOverlay.GestureCallback import org.nitri.opentopo.overlay.OverlayHelper import org.nitri.opentopo.analytics.AnalyticsProvider +import org.nitri.opentopo.analytics.OrsOutcome +import org.nitri.opentopo.analytics.OrsStartSource +import org.nitri.opentopo.analytics.durationBucket import org.nitri.opentopo.util.MapOrientation import org.nitri.opentopo.util.OrientationSensor import org.nitri.opentopo.util.Utils @@ -585,14 +588,14 @@ class MapFragment : Fragment(), LocationListener, PopupMenu.OnMenuItemClickListe val language = locale.language.lowercase() listener?.getOpenRouteServiceClient()?.let { client -> val directions = Directions(client, profile) + val destinationCount = markers.size + val startSource = if (currentLocation != null) OrsStartSource.CURRENT_LOCATION else OrsStartSource.MARKER_ONLY + val startedAt = System.nanoTime() / 1_000_000 + val analytics = AnalyticsProvider.get(requireContext()) directions.getRouteGpx(coordinates, language, object : Directions.RouteGpxResult { override fun onSuccess(gpx: String) { - Log.d(TAG, "GPX: $gpx") - // Analytics: route calculation succeeded (Play flavor reports it; FOSS no-ops) - AnalyticsProvider.get(requireContext()).trackRouteCalculated( - profile = profile, - waypointCount = coordinates.size - ) + Log.d(TAG, "ORS route GPX received (${gpx.length} chars)") + trackRouteResult(OrsOutcome.SUCCESS) gpxViewModel?.markerCoordinates = markers gpxViewModel?.orsProfile = profile @@ -607,16 +610,26 @@ class MapFragment : Fragment(), LocationListener, PopupMenu.OnMenuItemClickListe } } - override fun onError(message: String) { - Log.e(TAG, "Error fetching GPX: $message") + override fun onEmpty() { + trackRouteResult(OrsOutcome.EMPTY) context?.let { ctx -> Toast.makeText( ctx, - "Error fetching GPX: $message", + R.string.unable_to_calculate_route, Toast.LENGTH_LONG ).show() } } + + override fun onError(category: org.nitri.opentopo.analytics.OrsErrorCategory) { + trackRouteResult(OrsOutcome.ERROR, category) + context?.let { Toast.makeText(it, R.string.unable_to_calculate_route, Toast.LENGTH_LONG).show() } + } + + private fun trackRouteResult(outcome: OrsOutcome, category: org.nitri.opentopo.analytics.OrsErrorCategory? = null) { + analytics.trackOrsRouteResult(outcome, profile, destinationCount, + startSource, durationBucket(System.nanoTime() / 1_000_000 - startedAt), category) + } } ) } diff --git a/app/src/main/java/org/nitri/opentopo/analytics/AnalyticsTracker.kt b/app/src/main/java/org/nitri/opentopo/analytics/AnalyticsTracker.kt index 186e944b..eac5f5a8 100644 --- a/app/src/main/java/org/nitri/opentopo/analytics/AnalyticsTracker.kt +++ b/app/src/main/java/org/nitri/opentopo/analytics/AnalyticsTracker.kt @@ -21,10 +21,17 @@ interface AnalyticsTracker { fileName: String? ) - fun trackRouteCalculated( - profile: String?, - waypointCount: Int - ) + fun trackOrsRouteResult(outcome: OrsOutcome, profile: String, destinationCount: Int, + startSource: OrsStartSource, durationBucket: DurationBucket, + errorCategory: OrsErrorCategory? = null) + + fun trackOrsSearchOpened(hasMapFocus: Boolean) + + fun trackOrsSearchResult(outcome: OrsOutcome, resultCount: Int, + durationBucket: DurationBucket, queryLengthBucket: QueryLengthBucket, + isRetry: Boolean, errorCategory: OrsErrorCategory? = null) + + fun trackOrsSearchSelection(resultPosition: ResultPositionBucket, resultCount: Int) fun trackMapLayerSelected(baseMap: String, overlay: String) @@ -40,7 +47,10 @@ object NoOpAnalyticsTracker : AnalyticsTracker { override fun trackScreen(screenName: String, screenClass: String) { /* no-op */ } override fun trackGpxLoaded(source: String, gpx: Gpx, fileName: String?) { /* no-op */ } override fun trackKmlLoaded(source: String, contentType: String, fileName: String?) { /* no-op */ } - override fun trackRouteCalculated(profile: String?, waypointCount: Int) { /* no-op */ } + override fun trackOrsRouteResult(outcome: OrsOutcome, profile: String, destinationCount: Int, startSource: OrsStartSource, durationBucket: DurationBucket, errorCategory: OrsErrorCategory?) { /* no-op */ } + override fun trackOrsSearchOpened(hasMapFocus: Boolean) { /* no-op */ } + override fun trackOrsSearchResult(outcome: OrsOutcome, resultCount: Int, durationBucket: DurationBucket, queryLengthBucket: QueryLengthBucket, isRetry: Boolean, errorCategory: OrsErrorCategory?) { /* no-op */ } + override fun trackOrsSearchSelection(resultPosition: ResultPositionBucket, resultCount: Int) { /* no-op */ } override fun trackMapLayerSelected(baseMap: String, overlay: String) { /* no-op */ } override fun trackMarkersImported(importedCount: Int, skippedCount: Int) { /* no-op */ } override fun trackMarkersExported(markerCount: Int) { /* no-op */ } diff --git a/app/src/main/java/org/nitri/opentopo/analytics/OrsAnalytics.kt b/app/src/main/java/org/nitri/opentopo/analytics/OrsAnalytics.kt new file mode 100644 index 00000000..85de5a2f --- /dev/null +++ b/app/src/main/java/org/nitri/opentopo/analytics/OrsAnalytics.kt @@ -0,0 +1,50 @@ +package org.nitri.opentopo.analytics + +import com.google.gson.JsonParseException +import retrofit2.HttpException +import java.io.IOException +import java.net.SocketTimeoutException + +enum class OrsOutcome(val value: String) { SUCCESS("success"), EMPTY("empty"), ERROR("error") } +enum class OrsStartSource(val value: String) { CURRENT_LOCATION("current_location"), MARKER_ONLY("marker_only") } +enum class DurationBucket(val value: String) { UNDER_1S("under_1s"), ONE_TO_THREE_SECONDS("1_3s"), OVER_3S("over_3s") } +enum class QueryLengthBucket(val value: String) { THREE_TO_FIVE("3_5"), SIX_TO_TEN("6_10"), OVER_TEN("over_10") } +enum class ResultPositionBucket(val value: String) { FIRST("1"), SECOND_OR_THIRD("2_3"), FOURTH_OR_LATER("4_plus") } +enum class OrsErrorCategory(val value: String) { + NETWORK("network"), TIMEOUT("timeout"), AUTH("auth"), RATE_LIMIT("rate_limit"), + SERVER("server"), INVALID_RESPONSE("invalid_response"), UNKNOWN("unknown") +} + +fun durationBucket(durationMillis: Long) = when { + durationMillis < 1_000 -> DurationBucket.UNDER_1S + durationMillis <= 3_000 -> DurationBucket.ONE_TO_THREE_SECONDS + else -> DurationBucket.OVER_3S +} + +fun queryLengthBucket(length: Int) = when (length) { + in 3..5 -> QueryLengthBucket.THREE_TO_FIVE + in 6..10 -> QueryLengthBucket.SIX_TO_TEN + else -> QueryLengthBucket.OVER_TEN +} + +/** [zeroBasedIndex] is intentionally positional; result data never enters analytics. */ +fun resultPositionBucket(zeroBasedIndex: Int) = when (zeroBasedIndex) { + 0 -> ResultPositionBucket.FIRST + 1, 2 -> ResultPositionBucket.SECOND_OR_THIRD + else -> ResultPositionBucket.FOURTH_OR_LATER +} + +fun classifyOrsError(error: Throwable): OrsErrorCategory = when (error) { + is SocketTimeoutException -> OrsErrorCategory.TIMEOUT + is HttpException -> classifyOrsHttpStatus(error.code()) + is JsonParseException -> OrsErrorCategory.INVALID_RESPONSE + is IOException -> OrsErrorCategory.NETWORK + else -> OrsErrorCategory.UNKNOWN +} + +fun classifyOrsHttpStatus(status: Int): OrsErrorCategory = when (status) { + 401, 403 -> OrsErrorCategory.AUTH + 429 -> OrsErrorCategory.RATE_LIMIT + in 500..599 -> OrsErrorCategory.SERVER + else -> OrsErrorCategory.UNKNOWN +} diff --git a/app/src/main/java/org/nitri/opentopo/ors/Directions.kt b/app/src/main/java/org/nitri/opentopo/ors/Directions.kt index 9ec113a5..27a365a0 100644 --- a/app/src/main/java/org/nitri/opentopo/ors/Directions.kt +++ b/app/src/main/java/org/nitri/opentopo/ors/Directions.kt @@ -6,6 +6,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.nitri.ors.OrsClient import org.nitri.ors.helper.RouteHelper +import org.nitri.opentopo.analytics.OrsErrorCategory +import org.nitri.opentopo.analytics.classifyOrsError class Directions(val client: OrsClient, private val profile: String) { @@ -19,12 +21,12 @@ class Directions(val client: OrsClient, private val profile: String) { if (gpxXml.isNotBlank()) { result.onSuccess(gpxXml) } else { - result.onError("Empty response body") + result.onEmpty() } } } catch (e: Exception) { withContext(Dispatchers.Main) { - result.onError("Failed to fetch GPX: ${e.message}") + result.onError(classifyOrsError(e)) } } } @@ -32,6 +34,7 @@ class Directions(val client: OrsClient, private val profile: String) { interface RouteGpxResult { fun onSuccess(gpx: String) - fun onError(message: String) + fun onEmpty() + fun onError(category: OrsErrorCategory) } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/nitri/opentopo/view/PlaceSearchDialogFragment.kt b/app/src/main/java/org/nitri/opentopo/view/PlaceSearchDialogFragment.kt index a623cd6f..a8fe5595 100644 --- a/app/src/main/java/org/nitri/opentopo/view/PlaceSearchDialogFragment.kt +++ b/app/src/main/java/org/nitri/opentopo/view/PlaceSearchDialogFragment.kt @@ -16,7 +16,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -53,6 +53,8 @@ import androidx.lifecycle.setViewTreeViewModelStoreOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import org.nitri.opentopo.MapFragment import org.nitri.opentopo.R +import org.nitri.opentopo.analytics.AnalyticsProvider +import org.nitri.opentopo.analytics.resultPositionBucket import org.nitri.opentopo.model.PlaceSearchResult import org.nitri.opentopo.ui.theme.OpenTopoTheme import org.nitri.opentopo.viewmodel.PlaceSearchUiState @@ -60,6 +62,8 @@ import org.nitri.opentopo.viewmodel.PlaceSearchViewModel class PlaceSearchDialogFragment : DialogFragment() { + private val analytics by lazy { AnalyticsProvider.get(requireContext()) } + private val viewModel: PlaceSearchViewModel by viewModels { val focusLon = arguments?.getDouble(ARG_FOCUS_LON) val focusLat = arguments?.getDouble(ARG_FOCUS_LAT) @@ -72,10 +76,20 @@ class PlaceSearchDialogFragment : DialogFragment() { } }, focusLon = focusLon, - focusLat = focusLat + focusLat = focusLat, + analytics = analytics ) } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (savedInstanceState == null) { + analytics.trackOrsSearchOpened( + arguments?.containsKey(ARG_FOCUS_LON) == true && arguments?.containsKey(ARG_FOCUS_LAT) == true + ) + } + } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val composeView = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) @@ -84,7 +98,8 @@ class PlaceSearchDialogFragment : DialogFragment() { PlaceSearchDialogContent( viewModel = viewModel, isInitialLaunch = savedInstanceState == null, - onResultSelected = { result -> + onResultSelected = { result, index, resultCount -> + analytics.trackOrsSearchSelection(resultPositionBucket(index), resultCount.coerceAtMost(10)) setFragmentResult( REQUEST_KEY, Bundle().apply { @@ -146,7 +161,7 @@ class PlaceSearchDialogFragment : DialogFragment() { private fun PlaceSearchDialogContent( viewModel: PlaceSearchViewModel, isInitialLaunch: Boolean, - onResultSelected: (PlaceSearchResult) -> Unit, + onResultSelected: (PlaceSearchResult, Int, Int) -> Unit, onDismiss: () -> Unit ) { val query by viewModel.query.collectAsState() @@ -249,14 +264,14 @@ private fun PlaceSearchDialogContent( .heightIn(max = 280.dp) .padding(vertical = 8.dp) ) { - items( + itemsIndexed( items = state.results, - key = { it.stableId } - ) { result -> + key = { _, item -> item.stableId } + ) { index, result -> Column( modifier = Modifier .fillMaxWidth() - .clickable { onResultSelected(result) } + .clickable { onResultSelected(result, index, state.results.size) } .padding(vertical = 12.dp, horizontal = 4.dp) ) { Text( diff --git a/app/src/main/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModel.kt b/app/src/main/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModel.kt index 51b36c8b..cab721ff 100644 --- a/app/src/main/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModel.kt +++ b/app/src/main/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModel.kt @@ -18,6 +18,12 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import org.nitri.opentopo.model.PlaceSearchResult +import org.nitri.opentopo.analytics.AnalyticsTracker +import org.nitri.opentopo.analytics.NoOpAnalyticsTracker +import org.nitri.opentopo.analytics.OrsOutcome +import org.nitri.opentopo.analytics.classifyOrsError +import org.nitri.opentopo.analytics.durationBucket +import org.nitri.opentopo.analytics.queryLengthBucket import org.nitri.opentopo.ors.mapGeocodeFeaturesToPlaceSearchResults import org.nitri.ors.OrsClient import kotlin.coroutines.cancellation.CancellationException @@ -34,9 +40,13 @@ sealed interface PlaceSearchUiState { class PlaceSearchViewModel( private val clientProvider: () -> OrsClient?, private val focusLon: Double?, - private val focusLat: Double? + private val focusLat: Double?, + private val analytics: AnalyticsTracker = NoOpAnalyticsTracker, + private val clockMillis: () -> Long = { System.nanoTime() / 1_000_000 } ) : ViewModel() { + private data class SearchRequest(val query: String, val isRetry: Boolean) + private val _query = MutableStateFlow("") val query: StateFlow = _query.asStateFlow() @@ -52,12 +62,14 @@ class PlaceSearchViewModel( .debounce { normalizedQuery -> if (normalizedQuery.length >= 3) 400L else 0L } + .map { SearchRequest(it, false) } - val retryQuery = _retryTrigger.map { _query.value.trim() } + val retryQuery = _retryTrigger.map { SearchRequest(_query.value.trim(), true) } merge(debouncedQuery, retryQuery) - .flatMapLatest { normalizedQuery -> + .flatMapLatest { request -> flow { + val normalizedQuery = request.query if (normalizedQuery.length < 3) { emit(PlaceSearchUiState.QueryTooShort) return@flow @@ -71,6 +83,7 @@ class PlaceSearchViewModel( emit(PlaceSearchUiState.Loading) + val startedAt = clockMillis() try { val response = client.geocodeAutocomplete( text = normalizedQuery, @@ -79,6 +92,10 @@ class PlaceSearchViewModel( size = 10 ) val results = mapGeocodeFeaturesToPlaceSearchResults(response.features) + val outcome = if (results.isEmpty()) OrsOutcome.EMPTY else OrsOutcome.SUCCESS + analytics.trackOrsSearchResult(outcome, results.size.coerceAtMost(10), + durationBucket(clockMillis() - startedAt), queryLengthBucket(normalizedQuery.length), + request.isRetry) if (results.isEmpty()) { emit(PlaceSearchUiState.Empty) } else { @@ -87,7 +104,10 @@ class PlaceSearchViewModel( } catch (e: CancellationException) { throw e } catch (e: Throwable) { - emit(PlaceSearchUiState.Error(e.localizedMessage)) + analytics.trackOrsSearchResult(OrsOutcome.ERROR, 0, + durationBucket(clockMillis() - startedAt), queryLengthBucket(normalizedQuery.length), + request.isRetry, classifyOrsError(e)) + emit(PlaceSearchUiState.Error(null)) } } } @@ -108,11 +128,12 @@ class PlaceSearchViewModel( class Factory( private val clientProvider: () -> OrsClient?, private val focusLon: Double?, - private val focusLat: Double? + private val focusLat: Double?, + private val analytics: AnalyticsTracker = NoOpAnalyticsTracker ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - return PlaceSearchViewModel(clientProvider, focusLon, focusLat) as T + return PlaceSearchViewModel(clientProvider, focusLon, focusLat, analytics) as T } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d1e029b4..0ff0126b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -185,6 +185,7 @@ This cannot be undone. Enter at least 3 characters No matching places found Unable to search places + Unable to calculate route Retry diff --git a/app/src/play/java/org/nitri/opentopo/analytics/FirebaseAnalyticsTracker.kt b/app/src/play/java/org/nitri/opentopo/analytics/FirebaseAnalyticsTracker.kt index ef95e1fc..27c257ab 100644 --- a/app/src/play/java/org/nitri/opentopo/analytics/FirebaseAnalyticsTracker.kt +++ b/app/src/play/java/org/nitri/opentopo/analytics/FirebaseAnalyticsTracker.kt @@ -7,7 +7,10 @@ import io.ticofab.androidgpxparser.parser.domain.Gpx private const val EVENT_GPX_LOADED = "gpx_loaded" private const val EVENT_KML_LOADED = "kml_loaded" -private const val EVENT_ROUTE_CALCULATED = "route_calculated" +private const val EVENT_ORS_ROUTE_RESULT = "ors_route_result" +private const val EVENT_ORS_SEARCH_OPENED = "ors_search_opened" +private const val EVENT_ORS_SEARCH_RESULT = "ors_search_result" +private const val EVENT_ORS_SEARCH_SELECTION = "ors_search_selection" private const val EVENT_MAP_LAYER_SELECTED = "map_layer_selected" private const val EVENT_MARKERS_IMPORTED = "markers_imported" private const val EVENT_MARKERS_EXPORTED = "markers_exported" @@ -28,7 +31,6 @@ class FirebaseAnalyticsTracker(context: Context) : AnalyticsTracker { override fun trackGpxLoaded(source: String, gpx: Gpx, fileName: String?) { val params = Bundle().apply { putString("source", source) - fileName?.let { putString("file_name", it) } putInt("track_count", gpx.tracks?.size ?: 0) putInt("route_count", gpx.routes?.size ?: 0) putInt("waypoint_count", gpx.wayPoints?.size ?: 0) @@ -40,17 +42,45 @@ class FirebaseAnalyticsTracker(context: Context) : AnalyticsTracker { val params = Bundle().apply { putString("source", source) putString(FirebaseAnalytics.Param.CONTENT_TYPE, contentType) - fileName?.let { putString("file_name", it) } } firebase.logEvent(EVENT_KML_LOADED, params) } - override fun trackRouteCalculated(profile: String?, waypointCount: Int) { + override fun trackOrsRouteResult(outcome: OrsOutcome, profile: String, destinationCount: Int, + startSource: OrsStartSource, durationBucket: DurationBucket, errorCategory: OrsErrorCategory?) { val params = Bundle().apply { - profile?.let { putString("profile", it) } - putInt("waypoint_count", waypointCount) + putString("outcome", outcome.value) + putString("profile", profile) + putInt("destination_count", destinationCount) + putString("start_source", startSource.value) + putString("duration_bucket", durationBucket.value) + if (outcome == OrsOutcome.ERROR) errorCategory?.let { putString("error_category", it.value) } } - firebase.logEvent(EVENT_ROUTE_CALCULATED, params) + firebase.logEvent(EVENT_ORS_ROUTE_RESULT, params) + } + + override fun trackOrsSearchOpened(hasMapFocus: Boolean) { + firebase.logEvent(EVENT_ORS_SEARCH_OPENED, Bundle().apply { putBoolean("has_map_focus", hasMapFocus) }) + } + + override fun trackOrsSearchResult(outcome: OrsOutcome, resultCount: Int, + durationBucket: DurationBucket, queryLengthBucket: QueryLengthBucket, + isRetry: Boolean, errorCategory: OrsErrorCategory?) { + firebase.logEvent(EVENT_ORS_SEARCH_RESULT, Bundle().apply { + putString("outcome", outcome.value) + putInt("result_count", resultCount.coerceIn(0, 10)) + putString("duration_bucket", durationBucket.value) + putString("query_length_bucket", queryLengthBucket.value) + putBoolean("is_retry", isRetry) + if (outcome == OrsOutcome.ERROR) errorCategory?.let { putString("error_category", it.value) } + }) + } + + override fun trackOrsSearchSelection(resultPosition: ResultPositionBucket, resultCount: Int) { + firebase.logEvent(EVENT_ORS_SEARCH_SELECTION, Bundle().apply { + putString("result_position", resultPosition.value) + putInt("result_count", resultCount.coerceIn(0, 10)) + }) } override fun trackMapLayerSelected(baseMap: String, overlay: String) { diff --git a/app/src/test/java/org/nitri/opentopo/analytics/OrsAnalyticsTest.kt b/app/src/test/java/org/nitri/opentopo/analytics/OrsAnalyticsTest.kt new file mode 100644 index 00000000..51e2cccc --- /dev/null +++ b/app/src/test/java/org/nitri/opentopo/analytics/OrsAnalyticsTest.kt @@ -0,0 +1,43 @@ +package org.nitri.opentopo.analytics + +import com.google.gson.JsonSyntaxException +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.IOException +import java.net.SocketTimeoutException + +class OrsAnalyticsTest { + @Test fun durationBoundaries() { + assertEquals(DurationBucket.UNDER_1S, durationBucket(999)) + assertEquals(DurationBucket.ONE_TO_THREE_SECONDS, durationBucket(1_000)) + assertEquals(DurationBucket.ONE_TO_THREE_SECONDS, durationBucket(3_000)) + assertEquals(DurationBucket.OVER_3S, durationBucket(3_001)) + } + + @Test fun queryLengthBoundaries() { + assertEquals(QueryLengthBucket.THREE_TO_FIVE, queryLengthBucket(3)) + assertEquals(QueryLengthBucket.THREE_TO_FIVE, queryLengthBucket(5)) + assertEquals(QueryLengthBucket.SIX_TO_TEN, queryLengthBucket(6)) + assertEquals(QueryLengthBucket.SIX_TO_TEN, queryLengthBucket(10)) + assertEquals(QueryLengthBucket.OVER_TEN, queryLengthBucket(11)) + } + + @Test fun resultPositionBoundaries() { + assertEquals(ResultPositionBucket.FIRST, resultPositionBucket(0)) + assertEquals(ResultPositionBucket.SECOND_OR_THIRD, resultPositionBucket(1)) + assertEquals(ResultPositionBucket.SECOND_OR_THIRD, resultPositionBucket(2)) + assertEquals(ResultPositionBucket.FOURTH_OR_LATER, resultPositionBucket(3)) + } + + @Test fun errorsAreNormalizedByTypeAndStatus() { + assertEquals(OrsErrorCategory.TIMEOUT, classifyOrsError(SocketTimeoutException())) + assertEquals(OrsErrorCategory.NETWORK, classifyOrsError(IOException())) + assertEquals(OrsErrorCategory.INVALID_RESPONSE, classifyOrsError(JsonSyntaxException("bad"))) + assertEquals(OrsErrorCategory.AUTH, classifyOrsHttpStatus(401)) + assertEquals(OrsErrorCategory.AUTH, classifyOrsHttpStatus(403)) + assertEquals(OrsErrorCategory.RATE_LIMIT, classifyOrsHttpStatus(429)) + assertEquals(OrsErrorCategory.SERVER, classifyOrsHttpStatus(503)) + assertEquals(OrsErrorCategory.UNKNOWN, classifyOrsHttpStatus(400)) + assertEquals(OrsErrorCategory.UNKNOWN, classifyOrsError(IllegalStateException("private"))) + } +} diff --git a/app/src/test/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModelTest.kt b/app/src/test/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModelTest.kt index bdf78001..243ef4e9 100644 --- a/app/src/test/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModelTest.kt +++ b/app/src/test/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModelTest.kt @@ -41,10 +41,31 @@ import org.nitri.ors.domain.route.RouteResponse import org.nitri.ors.domain.snap.SnapGeoJsonResponse import org.nitri.ors.domain.snap.SnapRequest import org.nitri.ors.domain.snap.SnapResponse +import io.ticofab.androidgpxparser.parser.domain.Gpx +import org.nitri.opentopo.analytics.* +import java.io.IOException @OptIn(ExperimentalCoroutinesApi::class) class PlaceSearchViewModelTest { + private data class SearchEvent(val outcome: OrsOutcome, val count: Int, val retry: Boolean, val error: OrsErrorCategory?) + private class RecordingTracker : AnalyticsTracker { + val searchEvents = mutableListOf() + override fun trackOrsSearchResult(outcome: OrsOutcome, resultCount: Int, durationBucket: DurationBucket, queryLengthBucket: QueryLengthBucket, isRetry: Boolean, errorCategory: OrsErrorCategory?) { + searchEvents += SearchEvent(outcome, resultCount, isRetry, errorCategory) + } + override fun trackScreen(screenName: String, screenClass: String) = Unit + override fun trackGpxLoaded(source: String, gpx: Gpx, fileName: String?) = Unit + override fun trackKmlLoaded(source: String, contentType: String, fileName: String?) = Unit + override fun trackOrsRouteResult(outcome: OrsOutcome, profile: String, destinationCount: Int, startSource: OrsStartSource, durationBucket: DurationBucket, errorCategory: OrsErrorCategory?) = Unit + override fun trackOrsSearchOpened(hasMapFocus: Boolean) = Unit + override fun trackOrsSearchSelection(resultPosition: ResultPositionBucket, resultCount: Int) = Unit + override fun trackMapLayerSelected(baseMap: String, overlay: String) = Unit + override fun trackMarkersImported(importedCount: Int, skippedCount: Int) = Unit + override fun trackMarkersExported(markerCount: Int) = Unit + override fun trackMarkersDeleted(markerCount: Int) = Unit + } + private val testDispatcher = StandardTestDispatcher() @Before @@ -164,6 +185,7 @@ class PlaceSearchViewModelTest { @Test fun runningRequestIsCancelledByNewQuery() = runTest { var queryACancelled = false + val tracker = RecordingTracker() val fakeClient = FakeOrsClient { text -> if (text == "QueryA") { @@ -184,7 +206,7 @@ class PlaceSearchViewModelTest { } } - val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0) + val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0, tracker) viewModel.setQuery("QueryA") advanceTimeBy(400) @@ -201,6 +223,7 @@ class PlaceSearchViewModelTest { val results = (state as PlaceSearchUiState.Success).results assertEquals(1, results.size) assertEquals("ResultB", results[0].name) + assertEquals(listOf(OrsOutcome.SUCCESS), tracker.searchEvents.map { it.outcome }) } @Test @@ -242,34 +265,40 @@ class PlaceSearchViewModelTest { @Test fun emptyResponse_producesEmptyState() = runTest { + val tracker = RecordingTracker() val fakeClient = FakeOrsClient { GeocodeSearchResponse(features = emptyList()) } - val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0) + val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0, tracker) viewModel.setQuery("NonExistentPlace12345") advanceUntilIdle() assertEquals(1, fakeClient.callCount) assertEquals(PlaceSearchUiState.Empty, viewModel.uiState.value) + assertEquals(SearchEvent(OrsOutcome.EMPTY, 0, false, null), tracker.searchEvents.single()) } @Test fun serviceFailure_producesErrorState() = runTest { + val tracker = RecordingTracker() val fakeClient = FakeOrsClient { - throw RuntimeException("Network error") + throw IOException("private raw message") } - val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0) + val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0, tracker) viewModel.setQuery("Kleve") advanceUntilIdle() assertEquals(1, fakeClient.callCount) assertTrue(viewModel.uiState.value is PlaceSearchUiState.Error) + assertEquals(SearchEvent(OrsOutcome.ERROR, 0, false, OrsErrorCategory.NETWORK), tracker.searchEvents.single()) + assertEquals(null, (viewModel.uiState.value as PlaceSearchUiState.Error).message) } @Test fun retry_reissuesRequestWithoutModifyingQueryText() = runTest { + val tracker = RecordingTracker() var shouldFail = true val fakeClient = FakeOrsClient { text -> if (shouldFail) { @@ -286,7 +315,7 @@ class PlaceSearchViewModelTest { } } - val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0) + val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0, tracker) viewModel.setQuery("Kleve") advanceTimeBy(400) @@ -305,5 +334,6 @@ class PlaceSearchViewModelTest { assertTrue(viewModel.uiState.value is PlaceSearchUiState.Success) val success = viewModel.uiState.value as PlaceSearchUiState.Success assertEquals("Kleve", success.results[0].name) + assertEquals(listOf(false, true), tracker.searchEvents.map { it.retry }) } }