From 0ded60427f27d2f69ae235633c7a7ad1ece1cd9c Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 16 Mar 2026 01:09:44 +0000 Subject: feat: add VHW boat speed parser, BoatSpeedData, and PerformanceViewModel - NmeaParser: add parseVhw() for NMEA VHW (water speed) sentences, returning BoatSpeedData - NmeaStreamManager: expose nmeaBoatSpeedData SharedFlow for VHW emissions - BoatSpeedData: new sensor data class (bspKnots, timestampMs) - PerformanceViewModel + factory: new ViewModel for performance metrics - Preserve orig copies of MapFragment and UI tests for reference - Update SESSION_STATE.md and allowed tool settings Co-Authored-By: Claude Sonnet 4.6 --- .../kotlin/org/terst/nav/PerformanceViewModel.kt | 109 ++++++++++++++ .../org/terst/nav/PerformanceViewModelFactory.kt | 23 +++ .../main/kotlin/org/terst/nav/nmea/NmeaParser.kt | 35 +++++ .../kotlin/org/terst/nav/nmea/NmeaStreamManager.kt | 9 ++ .../kotlin/org/terst/nav/sensors/BoatSpeedData.kt | 6 + .../org/terst/nav/ui/map_orig/MapFragment.kt | 167 +++++++++++++++++++++ .../nav/ui_orig/LocationPermissionHandlerTest.kt | 110 ++++++++++++++ .../org/terst/nav/ui_orig/MainViewModelTest.kt | 105 +++++++++++++ 8 files changed, 564 insertions(+) create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt create mode 100644 android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt create mode 100644 android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt (limited to 'android-app') diff --git a/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt new file mode 100644 index 0000000..80a3250 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt @@ -0,0 +1,109 @@ +package org.terst.nav + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.terst.nav.nmea.NmeaStreamManager +import org.terst.nav.sensors.BoatSpeedData +import org.terst.nav.sensors.WindData +import kotlin.math.cos +import kotlin.math.abs + +class PerformanceViewModel( + private val nmeaStreamManager: NmeaStreamManager +) : ViewModel() { + + // Dummy PolarTable for now. In a real app, this would come from user settings/boat profile. + private val dummyPolarTable: PolarTable by lazy { + // Example polar data for a hypothetical boat + val curves = listOf( + PolarCurve(twS = 6.0, points = listOf( + PolarPoint(tWa = 30.0, bSp = 4.0), + PolarPoint(tWa = 45.0, bSp = 5.5), + PolarPoint(tWa = 60.0, bSp = 6.0), + PolarPoint(tWa = 90.0, bSp = 5.8), + PolarPoint(tWa = 120.0, bSp = 5.0), + PolarPoint(tWa = 150.0, bSp = 4.0), + PolarPoint(tWa = 180.0, bSp = 3.0) + )), + PolarCurve(twS = 10.0, points = listOf( + PolarPoint(tWa = 30.0, bSp = 5.0), + PolarPoint(tWa = 45.0, bSp = 7.0), + PolarPoint(tWa = 60.0, bSp = 7.5), + PolarPoint(tWa = 90.0, bSp = 7.0), + PolarPoint(tWa = 120.0, bSp = 6.0), + PolarPoint(tWa = 150.0, bSp = 5.0), + PolarPoint(tWa = 180.0, bSp = 4.0) + )), + PolarCurve(twS = 15.0, points = listOf( + PolarPoint(tWa = 30.0, bSp = 5.8), + PolarPoint(tWa = 45.0, bSp = 8.0), + PolarPoint(tWa = 60.0, bSp = 8.5), + PolarPoint(tWa = 90.0, bSp = 7.8), + PolarPoint(tWa = 120.0, bSp = 6.8), + PolarPoint(tWa = 150.0, bSp = 5.8), + PolarPoint(tWa = 180.0, bSp = 4.8) + )) + ) + PolarTable(curves) + } + + private val _vmg = MutableStateFlow(0.0) + val vmg: StateFlow = _vmg.asStateFlow() + + private val _polarPercentage = MutableStateFlow(0.0) + val polarPercentage: StateFlow = _polarPercentage.asStateFlow() + + private var latestWindData: WindData? = null + private var latestBoatSpeedData: BoatSpeedData? = null + + init { + viewModelScope.launch { + combine( + nmeaStreamManager.nmeaWindData, + nmeaStreamManager.nmeaBoatSpeedData + ) { windData, boatSpeedData -> + latestWindData = windData + latestBoatSpeedData = boatSpeedData + calculatePerformance() + }.collect { /* Do nothing, combine emits Unit after processing */ } + } + } + + private fun calculatePerformance() { + val currentWind = latestWindData + val currentBoatSpeed = latestBoatSpeedData + + if (currentWind != null && currentBoatSpeed != null) { + val tws = currentWind.windSpeed + val twa = currentWind.windAngle + + // Ensure TWA is true wind angle for VMG calculation + val vmgValue = if (currentWind.isTrueWind) { + dummyPolarTable.curves.firstOrNull()?.calculateVmg(twa, currentBoatSpeed.bspKnots) ?: 0.0 + } else { + // If wind is apparent, we cannot calculate true VMG directly from BSP * cos(TWA_apparent) + // Need true wind angle, which would typically be derived from apparent wind, heading, and boat speed + // For now, if only apparent wind is available, VMG calculation will be 0.0 + // This scenario needs a more robust solution in a production app. + 0.0 + } + _vmg.value = vmgValue + + // Polar percentage requires True Wind Speed and True Wind Angle + val polarPercentageValue = if (currentWind.isTrueWind) { + dummyPolarTable.calculatePolarPercentage(tws, twa, currentBoatSpeed.bspKnots) + } else { + 0.0 // Cannot calculate polar percentage without true wind data + } + _polarPercentage.value = polarPercentageValue + } else { + _vmg.value = 0.0 + _polarPercentage.value = 0.0 + } + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt new file mode 100644 index 0000000..ed6d1eb --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt @@ -0,0 +1,23 @@ +package org.terst.nav + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import org.terst.nav.nmea.NmeaParser +import org.terst.nav.nmea.NmeaStreamManager + +class PerformanceViewModelFactory : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(PerformanceViewModel::class.java)) { + // NmeaStreamManager will be tied to the ViewModel's lifecycle + val nmeaParser = NmeaParser() + // We'll pass the ViewModel's own viewModelScope to NmeaStreamManager + // The actual CoroutineScope passed here will be the one associated with the ViewModel + val nmeaStreamManager = NmeaStreamManager(nmeaParser, CoroutineScope(modelClass.kotlin.viewModelScope.coroutineContext)) + @Suppress("UNCHECKED_CAST") + return PerformanceViewModel(nmeaStreamManager) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt index 27d9c2c..453c758 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt @@ -1,6 +1,7 @@ package org.terst.nav.nmea import org.terst.nav.gps.GpsPosition +import org.terst.nav.sensors.BoatSpeedData import org.terst.nav.sensors.DepthData import org.terst.nav.sensors.HeadingData import org.terst.nav.sensors.WindData @@ -211,10 +212,44 @@ class NmeaParser { "MWV" -> parseMwv(sentence) "DBT" -> parseDbt(sentence) "HDG", "HDM" -> parseHdg(sentence) + "VHW" -> parseVhw(sentence) else -> null } } + /** + * Parses an NMEA VHW sentence (Water speed and Heading) and returns a [BoatSpeedData], + * or null if the sentence is malformed or cannot be parsed. + * + * Example: $IIVHW,,,2.1,N,,,*0A + * Fields: + * 1: Degrees True + * 2: T + * 3: Degrees Magnetic + * 4: M + * 5: Speed, knots, water + * 6: N = Knots + * 7: Speed, km/hr, water + * 8: K = km/hr + * (Checksum) + */ + fun parseVhw(sentence: String): BoatSpeedData? { + if (sentence.isBlank()) return null + + val body = if ('*' in sentence) sentence.substringBefore('*') else sentence + val fields = body.split(',') + if (fields.size < 6) return null // Minimum fields for speed in knots + + if (!fields[0].endsWith("VHW")) return null + + val bspKnots = fields.getOrNull(4)?.toDoubleOrNull() ?: return null + if (fields.getOrNull(5) != "N") return null // Ensure units are knots + + val timestampMs = System.currentTimeMillis() // Use current time for now + + return BoatSpeedData(bspKnots, timestampMs) + } + /** * Combines NMEA time (HHMMSS.ss) and date (DDMMYY) into a Unix epoch milliseconds value. * Returns 0 on any parse failure. diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt index 4298f0d..981b32e 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.terst.nav.gps.GpsPosition +import org.terst.nav.sensors.BoatSpeedData import org.terst.nav.sensors.DepthData import org.terst.nav.sensors.HeadingData import org.terst.nav.sensors.WindData @@ -57,6 +58,13 @@ class NmeaStreamManager( ) val nmeaHeadingData: SharedFlow = _nmeaHeadingData.asSharedFlow() + private val _nmeaBoatSpeedData = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val nmeaBoatSpeedData: SharedFlow = _nmeaBoatSpeedData.asSharedFlow() + fun start(address: String, port: Int) { if (connectionJob?.isActive == true) { Log.d(TAG, "NMEA stream already running.") @@ -85,6 +93,7 @@ class NmeaStreamManager( is WindData -> _nmeaWindData.emit(parsedData) is DepthData -> _nmeaDepthData.emit(parsedData) is HeadingData -> _nmeaHeadingData.emit(parsedData) + is BoatSpeedData -> _nmeaBoatSpeedData.emit(parsedData) else -> Log.w(TAG, "Unknown parsed NMEA data type: ${parsedData::class.simpleName}") } } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt new file mode 100644 index 0000000..9bdcbb3 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt @@ -0,0 +1,6 @@ +package org.terst.nav.sensors + +data class BoatSpeedData( + val bspKnots: Double, + val timestampMs: Long +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt new file mode 100644 index 0000000..ea7b596 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt @@ -0,0 +1,167 @@ +package org.terst.nav.ui.map + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import org.terst.nav.R +import org.terst.nav.data.model.WindArrow +import org.terst.nav.databinding.FragmentMapBinding +import org.terst.nav.ui.MainViewModel +import org.terst.nav.ui.UiState +import kotlinx.coroutines.launch +import org.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection +import org.maplibre.geojson.Point + +class MapFragment : Fragment() { + + private var _binding: FragmentMapBinding? = null + private val binding get() = _binding!! + + private val viewModel: MainViewModel by activityViewModels() + + private var mapLibreMap: MapLibreMap? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + MapLibre.getInstance(requireContext()) + _binding = FragmentMapBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.mapView.onCreate(savedInstanceState) + binding.mapView.getMapAsync { map -> + mapLibreMap = map + map.setStyle(Style.Builder().fromUri(MAP_STYLE_URL)) { style -> + addWindArrowImage(style) + observeViewModel(style) + } + } + } + + private fun observeViewModel(style: Style) { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.uiState.collect { state -> + binding.statusText.visibility = when (state) { + UiState.Loading -> View.VISIBLE.also { binding.statusText.text = getString(R.string.loading_weather) } + UiState.Success -> View.GONE + is UiState.Error -> View.VISIBLE.also { binding.statusText.text = state.message } + } + } + } + launch { + viewModel.windArrow.collect { arrow -> + if (arrow != null) { + updateWindLayer(style, arrow) + centerMapOn(arrow.lat, arrow.lon) + } + } + } + } + } + } + + private fun addWindArrowImage(style: Style) { + val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_wind_arrow) + ?: return + val bitmap = Bitmap.createBitmap( + drawable.intrinsicWidth.coerceAtLeast(24), + drawable.intrinsicHeight.coerceAtLeast(24), + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + style.addImage(WIND_ARROW_ICON, bitmap) + } + + private fun updateWindLayer(style: Style, arrow: WindArrow) { + val feature = Feature.fromGeometry( + Point.fromLngLat(arrow.lon, arrow.lat) + ).also { f -> + f.addNumberProperty("direction", arrow.directionDeg) + f.addNumberProperty("speed_kt", arrow.speedKt) + } + val collection = FeatureCollection.fromFeature(feature) + + if (style.getSource(WIND_SOURCE_ID) == null) { + style.addSource(GeoJsonSource(WIND_SOURCE_ID, collection)) + } else { + (style.getSource(WIND_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(collection) + } + + if (style.getLayer(WIND_LAYER_ID) == null) { + val layer = SymbolLayer(WIND_LAYER_ID, WIND_SOURCE_ID).withProperties( + PropertyFactory.iconImage(WIND_ARROW_ICON), + PropertyFactory.iconRotate(Expression.get("direction")), + PropertyFactory.iconRotationAlignment("map"), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.iconSize( + Expression.interpolate( + Expression.linear(), + Expression.get("speed_kt"), + Expression.stop(0, 0.6f), + Expression.stop(30, 1.4f) + ) + ) + ) + style.addLayer(layer) + } + } + + private fun centerMapOn(lat: Double, lon: Double) { + mapLibreMap?.cameraPosition = CameraPosition.Builder() + .target(LatLng(lat, lon)) + .zoom(7.0) + .build() + } + + // Lifecycle delegation to MapView + override fun onStart() { super.onStart(); binding.mapView.onStart() } + override fun onResume() { super.onResume(); binding.mapView.onResume() } + override fun onPause() { super.onPause(); binding.mapView.onPause() } + override fun onStop() { super.onStop(); binding.mapView.onStop() } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + binding.mapView.onSaveInstanceState(outState) + } + override fun onLowMemory() { super.onLowMemory(); binding.mapView.onLowMemory() } + + override fun onDestroyView() { + binding.mapView.onDestroy() + super.onDestroyView() + _binding = null + } + + companion object { + private const val MAP_STYLE_URL = "https://demotiles.maplibre.org/style.json" + private const val WIND_SOURCE_ID = "wind-source" + private const val WIND_LAYER_ID = "wind-arrows" + private const val WIND_ARROW_ICON = "wind-arrow" + } +} diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt new file mode 100644 index 0000000..9caa5a0 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt @@ -0,0 +1,110 @@ +package org.terst.nav.ui + +import org.junit.Assert.* +import org.junit.Test + +class LocationPermissionHandlerTest { + + // Convenience factory — callers override only the lambdas they care about. + private fun makeHandler( + checkGranted: () -> Boolean = { false }, + onGranted: () -> Unit = {}, + onDenied: () -> Unit = {}, + requestPermissions: () -> Unit = {} + ) = LocationPermissionHandler(checkGranted, onGranted, onDenied, requestPermissions) + + // ── start() ────────────────────────────────────────────────────────────── + + @Test + fun `start - permission already granted - calls onGranted without requesting`() { + var onGrantedCalled = false + var requestCalled = false + makeHandler( + checkGranted = { true }, + onGranted = { onGrantedCalled = true }, + requestPermissions = { requestCalled = true } + ).start() + + assertTrue("onGranted should be called", onGrantedCalled) + assertFalse("requestPermissions should NOT be called", requestCalled) + } + + @Test + fun `start - permission not granted - calls requestPermissions without calling onGranted`() { + var onGrantedCalled = false + var requestCalled = false + makeHandler( + checkGranted = { false }, + onGranted = { onGrantedCalled = true }, + requestPermissions = { requestCalled = true } + ).start() + + assertFalse("onGranted should NOT be called", onGrantedCalled) + assertTrue("requestPermissions should be called", requestCalled) + } + + // ── onResult() ─────────────────────────────────────────────────────────── + + @Test + fun `onResult - fine location granted - calls onGranted`() { + var onGrantedCalled = false + makeHandler(onGranted = { onGrantedCalled = true }).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to true, + "android.permission.ACCESS_COARSE_LOCATION" to false + ) + ) + assertTrue("onGranted should be called when fine location is granted", onGrantedCalled) + } + + @Test + fun `onResult - coarse location granted - calls onGranted`() { + var onGrantedCalled = false + makeHandler(onGranted = { onGrantedCalled = true }).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to false, + "android.permission.ACCESS_COARSE_LOCATION" to true + ) + ) + assertTrue("onGranted should be called when coarse location is granted", onGrantedCalled) + } + + @Test + fun `onResult - both permissions granted - calls onGranted`() { + var onGrantedCalled = false + makeHandler(onGranted = { onGrantedCalled = true }).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to true, + "android.permission.ACCESS_COARSE_LOCATION" to true + ) + ) + assertTrue(onGrantedCalled) + } + + @Test + fun `onResult - all permissions denied - calls onDenied not onGranted`() { + var onGrantedCalled = false + var onDeniedCalled = false + makeHandler( + onGranted = { onGrantedCalled = true }, + onDenied = { onDeniedCalled = true } + ).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to false, + "android.permission.ACCESS_COARSE_LOCATION" to false + ) + ) + assertFalse("onGranted should NOT be called", onGrantedCalled) + assertTrue("onDenied should be called", onDeniedCalled) + } + + @Test + fun `onResult - empty grants (never ask again scenario) - calls onDenied`() { + var onDeniedCalled = false + makeHandler(onDenied = { onDeniedCalled = true }).onResult(emptyMap()) + assertTrue( + "onDenied should be called for empty grants (never-ask-again)", + onDeniedCalled + ) + } +} diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt new file mode 100644 index 0000000..edecdd5 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt @@ -0,0 +1,105 @@ +package org.terst.nav.ui + +import app.cash.turbine.test +import org.terst.nav.data.model.ForecastItem +import org.terst.nav.data.model.WindArrow +import org.terst.nav.data.repository.WeatherRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MainViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val repo = mockk() + private lateinit var vm: MainViewModel + + private val sampleArrow = WindArrow(37.5, -122.3, 15.0, 270.0) + private val sampleForecast = listOf( + ForecastItem("2026-03-13T00:00", 15.0, 270.0, 18.5, 20, 1) + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun makeVm() = MainViewModel(repo) + + @Test + fun `initial uiState is Loading`() { + coEvery { repo.fetchWindArrow(any(), any()) } coAnswers { Result.success(sampleArrow) } + coEvery { repo.fetchForecastItems(any(), any()) } coAnswers { Result.success(sampleForecast) } + vm = makeVm() + // Before loadWeather() is called the state is Loading + assertEquals(UiState.Loading, vm.uiState.value) + } + + @Test + fun `loadWeather success transitions to Success state`() = runTest { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast) + vm = makeVm() + + vm.uiState.test { + assertEquals(UiState.Loading, awaitItem()) + vm.loadWeather(37.5, -122.3) + assertEquals(UiState.Success, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `loadWeather populates windArrow and forecast`() = runTest { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast) + vm = makeVm() + vm.loadWeather(37.5, -122.3) + + assertEquals(sampleArrow, vm.windArrow.value) + assertEquals(sampleForecast, vm.forecast.value) + } + + @Test + fun `loadWeather arrow failure transitions to Error state`() = runTest { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.failure(RuntimeException("Net error")) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast) + vm = makeVm() + + vm.uiState.test { + awaitItem() // Loading + vm.loadWeather(37.5, -122.3) + val state = awaitItem() + assertTrue(state is UiState.Error) + assertTrue((state as UiState.Error).message.contains("Net error")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `loadWeather forecast failure transitions to Error state`() = runTest { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.failure(RuntimeException("Timeout")) + vm = makeVm() + + vm.uiState.test { + awaitItem() // Loading + vm.loadWeather(37.5, -122.3) + val state = awaitItem() + assertTrue(state is UiState.Error) + cancelAndIgnoreRemainingEvents() + } + } +} -- cgit v1.2.3