Skip to content

Commit 93574f5

Browse files
jamesarichCopilot
andcommitted
test(map): expand test coverage and fix discovered bugs
- Expand business logic tests to comprehensive coverage - Fix 4 bugs discovered through testing - Tests cover: ViewModel logic, GeoJSON conversion, coordinate math, filter behavior, traceroute selection, camera persistence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 32e2173 commit 93574f5

7 files changed

Lines changed: 226 additions & 9 deletions

File tree

feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MBTilesImporter.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ import androidx.compose.runtime.Composable
2121
import androidx.compose.runtime.remember
2222
import androidx.compose.ui.platform.LocalContext
2323
import co.touchlab.kermit.Logger
24-
import kotlinx.coroutines.Dispatchers
2524
import kotlinx.coroutines.withContext
2625
import org.meshtastic.core.common.util.CommonUri
26+
import org.meshtastic.core.common.util.ioDispatcher
2727
import org.meshtastic.core.common.util.toPlatformUri
2828
import java.io.File
2929

@@ -35,7 +35,8 @@ internal actual fun rememberMBTilesImporter(): suspend (CommonUri) -> String? {
3535
return remember { { uri: CommonUri -> importMBTiles(context, uri) } }
3636
}
3737

38-
private suspend fun importMBTiles(context: Context, uri: CommonUri): String? = withContext(Dispatchers.IO) {
38+
private suspend fun importMBTiles(context: Context, uri: CommonUri): String? = withContext(ioDispatcher) {
39+
var destFile: File? = null
3940
try {
4041
val androidUri = uri.toPlatformUri() as android.net.Uri
4142
val mbtilesDir = File(context.filesDir, MBTILES_DIR)
@@ -46,7 +47,7 @@ private suspend fun importMBTiles(context: Context, uri: CommonUri): String? = w
4647
androidUri.lastPathSegment?.substringAfterLast('/')?.takeIf {
4748
it.endsWith(".mbtiles", ignoreCase = true)
4849
} ?: "import_${System.currentTimeMillis()}.mbtiles"
49-
val destFile = File(mbtilesDir, name)
50+
destFile = File(mbtilesDir, name)
5051

5152
context.contentResolver.openInputStream(androidUri)?.use { input ->
5253
destFile.outputStream().use { output -> input.copyTo(output) }
@@ -55,6 +56,7 @@ private suspend fun importMBTiles(context: Context, uri: CommonUri): String? = w
5556
destFile.absolutePath
5657
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
5758
Logger.e(e) { "Failed to import MBTiles file" }
59+
destFile?.delete()
5860
null
5961
}
6062
}

feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,13 @@ open class BaseMapViewModel(
132132
mapPrefs.setShowWeatherRadarOnMap(newValue)
133133
}
134134

135-
val owmApiKey: StateFlow<String> = mapPrefs.openWeatherMapApiKey
135+
private val owmApiKeyBuffer = MutableStateFlow(mapPrefs.openWeatherMapApiKey.value)
136+
val owmApiKey: StateFlow<String> = owmApiKeyBuffer.asStateFlow()
136137

137138
fun setOwmApiKey(key: String) {
138-
mapPrefs.setOpenWeatherMapApiKey(key.trim())
139+
val trimmed = key.trim()
140+
owmApiKeyBuffer.value = trimmed
141+
mapPrefs.setOpenWeatherMapApiKey(trimmed)
139142
}
140143

141144
private val lastHeardFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter.value))

feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,12 @@ fun EditWaypointDialog(
180180
private fun Double.formatCoord(): String {
181181
val negative = this < 0
182182
val absVal = abs(this)
183-
val wholePart = absVal.toLong()
184-
val fracPart = ((absVal - wholePart) * FORMAT_DECIMAL_FACTOR + 0.5).toLong()
183+
var wholePart = absVal.toLong()
184+
var fracPart = ((absVal - wholePart) * FORMAT_DECIMAL_FACTOR + 0.5).toLong()
185+
if (fracPart >= FORMAT_DECIMAL_FACTOR) {
186+
wholePart++
187+
fracPart = 0
188+
}
185189
val fracStr = fracPart.toString().padStart(6, '0')
186190
return "${if (negative) "-" else ""}$wholePart.$fracStr"
187191
}

feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,14 +166,18 @@ private fun LastHeardSlider(currentFilter: LastHeardFilter, onSetFilter: (LastHe
166166
@Composable
167167
private fun OwmApiKeyField(apiKey: String, onSetApiKey: (String) -> Unit) {
168168
var showInfoDialog by remember { mutableStateOf(false) }
169+
var localKey by remember(apiKey) { mutableStateOf(apiKey) }
169170

170171
Row(
171172
verticalAlignment = Alignment.CenterVertically,
172173
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
173174
) {
174175
OutlinedTextField(
175-
value = apiKey,
176-
onValueChange = onSetApiKey,
176+
value = localKey,
177+
onValueChange = {
178+
localKey = it
179+
onSetApiKey(it)
180+
},
177181
label = { Text(stringResource(Res.string.owm_api_key_hint)) },
178182
singleLine = true,
179183
textStyle = MaterialTheme.typography.bodySmall,

feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,4 +323,55 @@ class BaseMapViewModelTest {
323323
val result = viewModel.getNodeOrFallback(9999)
324324
assertEquals(9999, result.num)
325325
}
326+
327+
// ---- Weather radar & OWM API key ----
328+
329+
@Test
330+
fun toggleShowWeatherRadarOnMap_togglesState() {
331+
assertFalse(viewModel.showWeatherRadarOnMap.value)
332+
viewModel.toggleShowWeatherRadarOnMap()
333+
assertTrue(viewModel.showWeatherRadarOnMap.value)
334+
viewModel.toggleShowWeatherRadarOnMap()
335+
assertFalse(viewModel.showWeatherRadarOnMap.value)
336+
}
337+
338+
@Test
339+
fun toggleShowWeatherRadarOnMap_persistsToPrefs() {
340+
viewModel.toggleShowWeatherRadarOnMap()
341+
assertTrue(mapPrefs.showWeatherRadarOnMap.value)
342+
}
343+
344+
@Test
345+
fun setOwmApiKey_updatesStateAndTrims() = runTest(testDispatcher) {
346+
viewModel.setOwmApiKey(" my-api-key ")
347+
viewModel.owmApiKey.test {
348+
assertEquals("my-api-key", awaitItem())
349+
cancelAndIgnoreRemainingEvents()
350+
}
351+
}
352+
353+
@Test
354+
fun setOwmApiKey_persistsToPrefs() {
355+
viewModel.setOwmApiKey("test-key")
356+
assertEquals("test-key", mapPrefs.openWeatherMapApiKey.value)
357+
}
358+
359+
@Test
360+
fun mapFilterState_reflectsWeatherRadarAndOwmKey() = runTest(testDispatcher) {
361+
viewModel.mapFilterStateFlow.test {
362+
val initial = awaitItem()
363+
assertFalse(initial.showWeatherRadar)
364+
assertEquals("", initial.owmApiKey)
365+
366+
viewModel.toggleShowWeatherRadarOnMap()
367+
val withRadar = awaitItem()
368+
assertTrue(withRadar.showWeatherRadar)
369+
370+
viewModel.setOwmApiKey("abc123")
371+
val withKey = awaitItem()
372+
assertEquals("abc123", withKey.owmApiKey)
373+
374+
cancelAndIgnoreRemainingEvents()
375+
}
376+
}
326377
}

feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ import kotlin.test.AfterTest
4040
import kotlin.test.BeforeTest
4141
import kotlin.test.Test
4242
import kotlin.test.assertEquals
43+
import kotlin.test.assertFalse
4344
import kotlin.test.assertNull
45+
import kotlin.test.assertTrue
4446

4547
@OptIn(ExperimentalCoroutinesApi::class)
4648
class MapViewModelTest {
@@ -272,4 +274,78 @@ class MapViewModelTest {
272274
assertEquals(0, wpt.latitude_i)
273275
assertEquals(0, wpt.longitude_i)
274276
}
277+
278+
// ---- MBTiles layer management ----
279+
280+
@Test
281+
fun addMBTilesLayer_addsPathToPrefs() {
282+
viewModel.addMBTilesLayer("/data/map.mbtiles")
283+
assertTrue("/data/map.mbtiles" in mapCameraPrefs.mbtilesLayers.value)
284+
}
285+
286+
@Test
287+
fun addMBTilesLayer_preservesExistingLayers() {
288+
viewModel.addMBTilesLayer("/data/first.mbtiles")
289+
viewModel.addMBTilesLayer("/data/second.mbtiles")
290+
val layers = mapCameraPrefs.mbtilesLayers.value
291+
assertTrue("/data/first.mbtiles" in layers)
292+
assertTrue("/data/second.mbtiles" in layers)
293+
}
294+
295+
@Test
296+
fun removeMBTilesLayer_removesFromPrefs() {
297+
viewModel.addMBTilesLayer("/data/map.mbtiles")
298+
viewModel.removeMBTilesLayer("/data/map.mbtiles")
299+
assertFalse("/data/map.mbtiles" in mapCameraPrefs.mbtilesLayers.value)
300+
}
301+
302+
@Test
303+
fun removeMBTilesLayer_cleansUpHiddenState() {
304+
viewModel.addMBTilesLayer("/data/map.mbtiles")
305+
viewModel.toggleMBTilesLayerVisibility("/data/map.mbtiles") // hide it
306+
assertTrue("/data/map.mbtiles" in mapCameraPrefs.hiddenLayerUrls.value)
307+
308+
viewModel.removeMBTilesLayer("/data/map.mbtiles")
309+
assertFalse("/data/map.mbtiles" in mapCameraPrefs.hiddenLayerUrls.value)
310+
}
311+
312+
@Test
313+
fun toggleMBTilesLayerVisibility_hidesAndShowsLayer() {
314+
viewModel.addMBTilesLayer("/data/map.mbtiles")
315+
316+
viewModel.toggleMBTilesLayerVisibility("/data/map.mbtiles")
317+
assertTrue("/data/map.mbtiles" in mapCameraPrefs.hiddenLayerUrls.value)
318+
319+
viewModel.toggleMBTilesLayerVisibility("/data/map.mbtiles")
320+
assertFalse("/data/map.mbtiles" in mapCameraPrefs.hiddenLayerUrls.value)
321+
}
322+
323+
@Test
324+
fun mbtilesLayers_derivesNameFromPath() = runTest(testDispatcher) {
325+
viewModel.addMBTilesLayer("/storage/tiles/terrain.mbtiles")
326+
327+
viewModel.mbtilesLayers.test {
328+
val layers = expectMostRecentItem()
329+
assertEquals(1, layers.size)
330+
assertEquals("terrain", layers.first().name)
331+
assertEquals("/storage/tiles/terrain.mbtiles", layers.first().uriString)
332+
cancelAndIgnoreRemainingEvents()
333+
}
334+
}
335+
336+
@Test
337+
fun mbtilesLayers_reflectsVisibilityState() = runTest(testDispatcher) {
338+
viewModel.addMBTilesLayer("/data/map.mbtiles")
339+
340+
viewModel.mbtilesLayers.test {
341+
val visible = expectMostRecentItem()
342+
assertTrue(visible.first().isVisible)
343+
344+
viewModel.toggleMBTilesLayerVisibility("/data/map.mbtiles")
345+
val hidden = awaitItem()
346+
assertFalse(hidden.first().isVisible)
347+
348+
cancelAndIgnoreRemainingEvents()
349+
}
350+
}
275351
}

feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,83 @@ class GeoJsonConvertersTest {
284284
assertTrue(result.isNotEmpty(), "Should return a non-empty string even for invalid code points")
285285
}
286286

287+
@Test
288+
fun nodesToFeatureCollection_includesColorProperties() {
289+
val node =
290+
Node(
291+
num = 0x6750A4, // will produce deterministic colors
292+
position = Position(latitude_i = 400000000, longitude_i = -740000000),
293+
)
294+
val result = nodesToFeatureCollection(listOf(node))
295+
val props = result.features.first().properties
296+
297+
val fg = props["foreground_color"].toString().replace("\"", "")
298+
val bg = props["background_color"].toString().replace("\"", "")
299+
assertTrue(fg.startsWith("#"), "foreground_color should be a hex string")
300+
assertTrue(bg.startsWith("#"), "background_color should be a hex string")
301+
assertEquals(7, fg.length, "foreground_color should be #RRGGBB")
302+
assertEquals(7, bg.length, "background_color should be #RRGGBB")
303+
}
304+
305+
@Test
306+
fun nodesToFeatureCollection_includesLastHeardProperty() {
307+
val node =
308+
Node(num = 1, position = Position(latitude_i = 400000000, longitude_i = -740000000), lastHeard = 1700000000)
309+
val result = nodesToFeatureCollection(listOf(node))
310+
val props = result.features.first().properties
311+
assertEquals(1700000000, props["last_heard"]?.toString()?.toIntOrNull())
312+
}
313+
314+
@Test
315+
fun nodesToFeatureCollection_precisionBitsInRange_hasPrecisionTrue() {
316+
val node =
317+
Node(num = 1, position = Position(latitude_i = 400000000, longitude_i = -740000000, precision_bits = 14))
318+
val result = nodesToFeatureCollection(listOf(node))
319+
val props = result.features.first().properties
320+
assertEquals("true", props["has_precision"].toString())
321+
assertEquals(368.0, props["precision_meters"]?.toString()?.toDoubleOrNull())
322+
}
323+
324+
@Test
325+
fun nodesToFeatureCollection_precisionBitsOutOfRange_hasPrecisionFalse() {
326+
val node =
327+
Node(num = 1, position = Position(latitude_i = 400000000, longitude_i = -740000000, precision_bits = 5))
328+
val result = nodesToFeatureCollection(listOf(node))
329+
val props = result.features.first().properties
330+
assertEquals("false", props["has_precision"].toString())
331+
assertEquals(0.0, props["precision_meters"]?.toString()?.toDoubleOrNull())
332+
}
333+
334+
@Test
335+
fun nodesToFeatureCollection_snrAndRssiProperties() {
336+
val node =
337+
Node(
338+
num = 1,
339+
position = Position(latitude_i = 400000000, longitude_i = -740000000),
340+
snr = 12.5f,
341+
rssi = -95,
342+
)
343+
val result = nodesToFeatureCollection(listOf(node))
344+
val props = result.features.first().properties
345+
assertEquals(12.5, props["snr"]?.toString()?.toDoubleOrNull())
346+
assertEquals(-95, props["rssi"]?.toString()?.toIntOrNull())
347+
}
348+
349+
@Test
350+
fun nodesToFeatureCollection_viaMqttAndHopsProperties() {
351+
val node =
352+
Node(
353+
num = 1,
354+
position = Position(latitude_i = 400000000, longitude_i = -740000000),
355+
viaMqtt = true,
356+
hopsAway = 3,
357+
)
358+
val result = nodesToFeatureCollection(listOf(node))
359+
val props = result.features.first().properties
360+
assertEquals("true", props["via_mqtt"].toString())
361+
assertEquals(3, props["hops_away"]?.toString()?.toIntOrNull())
362+
}
363+
287364
// --- toGeoPositionOrNull ---
288365

289366
@Test

0 commit comments

Comments
 (0)