1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
|
Context
-------
MainViewModel.addGpsPoint() (android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt,
line 67) builds TrackPoint with hardcoded wind zeroes:
windSpeedKnots = 0.0, windAngleDeg = 0.0, isTrueWind = false
LocationService already exposes live NMEA wind data:
LocationService.nmeaWindDataFlow: SharedFlow<WindData> (companion object, line 362)
WindData (android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt):
windAngle: Double // degrees, relative or true
windSpeed: Double // knots
isTrueWind: Boolean
timestampMs: Long
TrackPoint (android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt) already has
windSpeedKnots, windAngleDeg, isTrueWind fields — they just aren't populated.
Goal
----
Cache the latest WindData in MainViewModel and use it when building TrackPoints.
Default to zero wind if no NMEA wind sentence has arrived yet.
Step 1 — Red: write failing tests
----------------------------------
Create android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelWindTest.kt
Setup boilerplate needed (Dispatchers.setMain / resetMain with UnconfinedTestDispatcher):
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.terst.nav.sensors.WindData
import org.terst.nav.ui.MainViewModel
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModelWindTest {
@get:Rule val instantTask = InstantTaskExecutorRule()
private val dispatcher = UnconfinedTestDispatcher()
@Before fun setUp() { Dispatchers.setMain(dispatcher) }
@After fun tearDown() { Dispatchers.resetMain() }
@Test fun `addGpsPoint uses zero wind when no wind update received`() = runTest {
val vm = MainViewModel()
vm.startTrack()
vm.addGpsPoint(37.0, -122.0, 5.0, 90.0)
val pt = vm.trackPoints.value.first()
assertEquals(0.0, pt.windSpeedKnots, 0.001)
assertEquals(0.0, pt.windAngleDeg, 0.001)
assertEquals(false, pt.isTrueWind)
}
@Test fun `addGpsPoint uses latest wind data after updateWind`() = runTest {
val vm = MainViewModel()
vm.updateWind(WindData(windAngle = 45.0, windSpeed = 15.5, isTrueWind = true, timestampMs = 1000L))
vm.startTrack()
vm.addGpsPoint(37.0, -122.0, 5.0, 90.0)
val pt = vm.trackPoints.value.first()
assertEquals(15.5, pt.windSpeedKnots, 0.001)
assertEquals(45.0, pt.windAngleDeg, 0.001)
assertEquals(true, pt.isTrueWind)
}
@Test fun `addGpsPoint reflects wind update between fixes`() = runTest {
val vm = MainViewModel()
vm.startTrack()
vm.addGpsPoint(37.0, -122.0, 5.0, 90.0)
vm.updateWind(WindData(windAngle = 180.0, windSpeed = 8.0, isTrueWind = false, timestampMs = 2000L))
vm.addGpsPoint(37.1, -122.0, 5.0, 90.0)
val pts = vm.trackPoints.value
assertEquals(0.0, pts[0].windSpeedKnots, 0.001)
assertEquals(8.0, pts[1].windSpeedKnots, 0.001)
}
}
Run and confirm RED:
cd android-app && ./gradlew :app:testDebugUnitTest --tests "*MainViewModelWind*"
Step 2 — Green: implement
--------------------------
In android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt:
1. Add import:
import org.terst.nav.sensors.WindData
2. Add field after trackRepository:
private var latestWind: WindData? = null
3. Add method:
fun updateWind(wind: WindData) {
latestWind = wind
}
4. Update addGpsPoint() to use latestWind:
fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
val wind = latestWind
val point = TrackPoint(
lat = lat, lon = lon,
sogKnots = sogKnots, cogDeg = cogDeg,
windSpeedKnots = wind?.windSpeed ?: 0.0,
windAngleDeg = wind?.windAngle ?: 0.0,
isTrueWind = wind?.isTrueWind ?: false,
timestampMs = System.currentTimeMillis()
)
if (trackRepository.addPoint(point)) {
_trackPoints.value = trackRepository.getPoints()
}
}
Run and confirm GREEN:
cd android-app && ./gradlew :app:testDebugUnitTest --tests "*MainViewModelWind*"
Step 3 — Wire: feed wind updates from LocationService
------------------------------------------------------
In android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt,
inside observeDataSources(), add after the anchorWatchState collector:
lifecycleScope.launch {
LocationService.nmeaWindDataFlow.collect { wind ->
viewModel.updateWind(wind)
}
}
No additional imports needed (LocationService is already imported).
Step 4 — Verify all tests pass
-------------------------------
cd android-app && ./gradlew :app:testDebugUnitTest
Confirm all existing tests still pass alongside the 3 new wind tests.
Step 5 — Commit and push
-------------------------
git add -A && git commit -m "feat(track): wire NMEA wind data into GPS track points
MainViewModel caches the latest WindData from LocationService.nmeaWindDataFlow
via updateWind(). addGpsPoint() populates TrackPoint wind fields from the cache,
defaulting to zero if no NMEA wind sentence has arrived yet.
MainActivity.observeDataSources() feeds LocationService.nmeaWindDataFlow
into viewModel.updateWind() alongside the existing GPS and anchor observers.
3 new unit tests in MainViewModelWindTest verify zero-default, wind capture,
and mid-track wind update behaviour." && git push origin main
|