refactor(map): architectural improvements — DRY, UDF, dead code, test coverage

- Extract COORDINATE_SCALE to shared MapConstants.kt, removing 6 duplicate
  private const declarations across MapScreen, GeoJsonConverters, InlineMap,
  NodeTrackMap, TracerouteLayers, and TracerouteMap
- Move node filtering from MapScreen composition into BaseMapViewModel as
  filteredNodes StateFlow (testable, avoids composition-time computation)
- Move waypoint construction from MapScreen's inline onSend callback into
  MapViewModel.createAndSendWaypoint() for testability and separation
- Remove unused compassBearing property from MapViewModel (bearing is read
  directly from cameraState.position.bearing in MapScreen)
- Add nodes parameter to TracerouteMap for short name resolution on hop
  markers (was hardcoded to emptyMap, falling back to hex node nums)
- Add GeoJsonConvertersTest with 25 tests covering nodesToFeatureCollection,
  waypointsToFeatureCollection, positionsToLineString, positionsToPointFeatures,
  precisionBitsToMeters, intToHexColor, and convertIntToEmoji
- Expand BaseMapViewModelTest from 5 to 21 tests covering filter toggles,
  preference persistence, mapFilterState composition, filteredNodes with
  favorites/last-heard/any filters, and getNodeOrFallback
- Expand MapViewModelTest from 9 to 12 tests covering createAndSendWaypoint
  with new/edit/locked/no-position scenarios
This commit is contained in:
James Rich
2026-04-13 13:04:02 -05:00
parent e3ab495b92
commit 5ad55fcbce
12 changed files with 664 additions and 83 deletions

View File

@@ -192,6 +192,20 @@ open class BaseMapViewModel(
lastHeardTrackFilter.value,
),
)
/** Nodes with position, filtered by favorites and last-heard preferences. */
val filteredNodes: StateFlow<List<Node>> =
combine(nodesWithPosition, mapFilterStateFlow) { nodes, filter ->
val myNum = myNodeNum
nodes
.filter { node -> !filter.onlyFavorites || node.isFavorite || node.num == myNum }
.filter { node ->
filter.lastHeardFilter.seconds == 0L ||
(nowSeconds - node.lastHeard) <= filter.lastHeardFilter.seconds ||
node.num == myNum
}
}
.stateInWhileSubscribed(initialValue = emptyList())
}
/**

View File

@@ -40,7 +40,6 @@ import org.maplibre.compose.location.LocationTrackingEffect
import org.maplibre.compose.location.rememberNullLocationProvider
import org.maplibre.compose.location.rememberUserLocationState
import org.maplibre.compose.map.GestureOptions
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map
import org.meshtastic.core.ui.component.MainAppBar
@@ -50,11 +49,9 @@ import org.meshtastic.feature.map.component.MapFilterDropdown
import org.meshtastic.feature.map.component.MapStyleSelector
import org.meshtastic.feature.map.component.MaplibreMapContent
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.proto.Waypoint
import org.meshtastic.feature.map.util.COORDINATE_SCALE
import org.maplibre.spatialk.geojson.Position as GeoPosition
/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */
private const val COORDINATE_SCALE = 1e-7
private const val WAYPOINT_ZOOM = 15.0
/**
@@ -75,7 +72,7 @@ fun MapScreen(
) {
val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val nodesWithPosition by viewModel.nodesWithPosition.collectAsStateWithLifecycle()
val filteredNodes by viewModel.filteredNodes.collectAsStateWithLifecycle()
val waypoints by viewModel.waypoints.collectAsStateWithLifecycle()
val filterState by viewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val baseStyle by viewModel.baseStyle.collectAsStateWithLifecycle()
@@ -130,19 +127,6 @@ fun MapScreen(
}
}
// Apply favorites and last-heard filters to the node list
val myNum = viewModel.myNodeNum
val filteredNodes =
remember(nodesWithPosition, filterState, myNum) {
nodesWithPosition
.filter { node -> !filterState.onlyFavorites || node.isFavorite || node.num == myNum }
.filter { node ->
filterState.lastHeardFilter.seconds == 0L ||
(nowSeconds - node.lastHeard) <= filterState.lastHeardFilter.seconds ||
node.num == myNum
}
}
@Suppress("ViewModelForwarding")
Scaffold(
modifier = modifier,
@@ -264,29 +248,15 @@ fun MapScreen(
longPressPosition = null
},
onSend = { name, description, icon, locked, expire ->
val myNodeNum = viewModel.myNodeNum ?: 0
val wpt =
Waypoint(
id = editingWaypoint?.id ?: viewModel.generatePacketId(),
name = name,
description = description,
icon = icon,
locked_to = if (locked) myNodeNum else 0,
latitude_i =
if (editingWaypoint != null) {
editingWaypoint.latitude_i
} else {
longPressPosition?.let { (it.latitude / COORDINATE_SCALE).toInt() } ?: 0
},
longitude_i =
if (editingWaypoint != null) {
editingWaypoint.longitude_i
} else {
longPressPosition?.let { (it.longitude / COORDINATE_SCALE).toInt() } ?: 0
},
expire = expire,
)
viewModel.sendWaypoint(wpt)
viewModel.createAndSendWaypoint(
name = name,
description = description,
icon = icon,
locked = locked,
expire = expire,
existingWaypoint = editingWaypoint,
position = longPressPosition,
)
},
onDelete = editingWaypoint?.let { wpt -> { viewModel.deleteWaypoint(wpt.id) } },
initialName = editingWaypoint?.name ?: "",

View File

@@ -31,6 +31,8 @@ import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.feature.map.util.COORDINATE_SCALE
import org.meshtastic.proto.Waypoint
import org.maplibre.spatialk.geojson.Position as GeoPosition
/**
@@ -94,7 +96,34 @@ class MapViewModel(
mapCameraPrefs.setSelectedStyleUri(style.styleUri)
}
/** Bearing for the compass in degrees. */
val compassBearing: Float
get() = mapCameraPrefs.cameraBearing.value
/**
* Create a [Waypoint] proto from user-provided fields, handling coordinate conversion and ID generation.
*
* @param existingWaypoint If non-null, the waypoint being edited (retains its id and coordinates).
* @param position If non-null, the long-press position for a new waypoint.
*/
fun createAndSendWaypoint(
name: String,
description: String,
icon: Int,
locked: Boolean,
expire: Int,
existingWaypoint: Waypoint?,
position: GeoPosition?,
) {
val wpt =
Waypoint(
id = existingWaypoint?.id ?: generatePacketId(),
name = name,
description = description,
icon = icon,
locked_to = if (locked) (myNodeNum ?: 0) else 0,
latitude_i =
existingWaypoint?.latitude_i ?: position?.let { (it.latitude / COORDINATE_SCALE).toInt() } ?: 0,
longitude_i =
existingWaypoint?.longitude_i ?: position?.let { (it.longitude / COORDINATE_SCALE).toInt() } ?: 0,
expire = expire,
)
sendWaypoint(wpt)
}
}

View File

@@ -37,11 +37,11 @@ import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.Point
import org.meshtastic.core.model.Node
import org.meshtastic.feature.map.util.COORDINATE_SCALE
import org.meshtastic.feature.map.util.precisionBitsToMeters
import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val DEFAULT_ZOOM = 15.0
private const val COORDINATE_SCALE = 1e-7
private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f
private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f

View File

@@ -30,11 +30,11 @@ import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.OrnamentOptions
import org.maplibre.spatialk.geojson.BoundingBox
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.feature.map.util.COORDINATE_SCALE
import org.meshtastic.proto.Position
import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val DEFAULT_TRACK_ZOOM = 13.0
private const val COORDINATE_SCALE = 1e-7
private const val BOUNDS_PADDING_DP = 48
/**

View File

@@ -40,12 +40,12 @@ import org.maplibre.spatialk.geojson.LineString
import org.maplibre.spatialk.geojson.Point
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.feature.map.util.COORDINATE_SCALE
import org.maplibre.spatialk.geojson.Position as GeoPosition
private val ForwardRouteColor = Color(0xFF4CAF50)
private val ReturnRouteColor = Color(0xFFF44336)
private val HopMarkerColor = Color(0xFF9C27B0)
private const val COORDINATE_SCALE = 1e-7
private const val HEX_RADIX = 16
private const val ROUTE_OPACITY = 0.8f

View File

@@ -29,13 +29,14 @@ import org.maplibre.compose.map.MapOptions
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.OrnamentOptions
import org.maplibre.spatialk.geojson.BoundingBox
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.feature.map.util.COORDINATE_SCALE
import org.meshtastic.proto.Position
import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val DEFAULT_TRACEROUTE_ZOOM = 10.0
private const val COORDINATE_SCALE = 1e-7
private const val BOUNDS_PADDING_DP = 64
/**
@@ -44,6 +45,9 @@ private const val BOUNDS_PADDING_DP = 64
* This composable is designed to be embedded inside a parent scaffold (e.g. TracerouteMapScreen). It does NOT include
* its own Scaffold or AppBar.
*
* @param nodes Node lookup map for resolving short names on hop markers. When empty, hop markers fall back to hex node
* numbers. Callers should pass `nodeRepository.nodeDBbyNum.value` (or equivalent) for readable labels.
*
* Replaces both the Google Maps and OSMDroid flavor-specific TracerouteMap implementations.
*/
@Composable
@@ -52,6 +56,7 @@ fun TracerouteMap(
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
nodes: Map<Int, Node> = emptyMap(),
) {
val geoPositions =
remember(tracerouteNodePositions) {
@@ -99,7 +104,7 @@ fun TracerouteMap(
TracerouteLayers(
overlay = tracerouteOverlay,
nodePositions = tracerouteNodePositions,
nodes = emptyMap(), // Node lookups for short names are best-effort
nodes = nodes,
onMappableCountChanged = onMappableCountChanged,
)
}

View File

@@ -27,9 +27,6 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.maplibre.spatialk.geojson.Position as GeoPosition
/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */
private const val COORDINATE_SCALE = 1e-7
private const val MIN_PRECISION_BITS = 10
private const val MAX_PRECISION_BITS = 19

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.util
/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */
const val COORDINATE_SCALE = 1e-7

View File

@@ -27,26 +27,32 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.testing.FakeMapPrefs
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.proto.Position
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("MagicNumber")
class BaseMapViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var viewModel: BaseMapViewModel
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var radioController: FakeRadioController
private val mapPrefs: MapPrefs = mock()
private lateinit var mapPrefs: FakeMapPrefs
private val packetRepository: PacketRepository = mock()
@BeforeTest
@@ -55,22 +61,9 @@ class BaseMapViewModelTest {
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
radioController.setConnectionState(ConnectionState.Disconnected)
every { mapPrefs.showOnlyFavorites } returns MutableStateFlow(false)
every { mapPrefs.showWaypointsOnMap } returns MutableStateFlow(false)
every { mapPrefs.showPrecisionCircleOnMap } returns MutableStateFlow(false)
every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L)
every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L)
mapPrefs = FakeMapPrefs()
every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList())
viewModel =
BaseMapViewModel(
mapPrefs = mapPrefs,
nodeRepository = nodeRepository,
packetRepository = packetRepository,
radioController = radioController,
)
viewModel = createViewModel()
}
@AfterTest
@@ -78,6 +71,28 @@ class BaseMapViewModelTest {
Dispatchers.resetMain()
}
private fun createViewModel(): BaseMapViewModel = BaseMapViewModel(
mapPrefs = mapPrefs,
nodeRepository = nodeRepository,
packetRepository = packetRepository,
radioController = radioController,
)
private fun nodeWithPosition(
num: Int,
latI: Int = 400000000,
lngI: Int = -740000000,
isFavorite: Boolean = false,
lastHeard: Int = nowSeconds.toInt(),
): Node = Node(
num = num,
position = Position(latitude_i = latI, longitude_i = lngI),
isFavorite = isFavorite,
lastHeard = lastHeard,
)
// ---- Initialization ----
@Test
fun testInitialization() {
assertNotNull(viewModel)
@@ -102,12 +117,9 @@ class BaseMapViewModelTest {
@Test
fun testConnectionStateFlow() = runTest(testDispatcher) {
viewModel.isConnected.test {
// Initially reflects radioController state (which is Disconnected in FakeRadioController default)
assertEquals(false, awaitItem())
radioController.setConnectionState(ConnectionState.Connected)
assertEquals(true, awaitItem())
radioController.setConnectionState(ConnectionState.Disconnected)
assertEquals(false, awaitItem())
cancelAndIgnoreRemainingEvents()
@@ -118,7 +130,166 @@ class BaseMapViewModelTest {
fun testNodeRepositoryIntegration() = runTest(testDispatcher) {
val testNodes = TestDataFactory.createTestNodes(3)
nodeRepository.setNodes(testNodes)
assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
}
// ---- Filter toggle tests ----
@Test
fun toggleOnlyFavorites_togglesState() {
assertFalse(viewModel.showOnlyFavoritesOnMap.value)
viewModel.toggleOnlyFavorites()
assertTrue(viewModel.showOnlyFavoritesOnMap.value)
viewModel.toggleOnlyFavorites()
assertFalse(viewModel.showOnlyFavoritesOnMap.value)
}
@Test
fun toggleOnlyFavorites_persistsToPrefs() {
viewModel.toggleOnlyFavorites()
assertTrue(mapPrefs.showOnlyFavorites.value)
}
@Test
fun toggleShowWaypointsOnMap_togglesState() {
// FakeMapPrefs defaults to true
assertTrue(viewModel.showWaypointsOnMap.value)
viewModel.toggleShowWaypointsOnMap()
assertFalse(viewModel.showWaypointsOnMap.value)
}
@Test
fun toggleShowPrecisionCircleOnMap_togglesState() {
assertTrue(viewModel.showPrecisionCircleOnMap.value)
viewModel.toggleShowPrecisionCircleOnMap()
assertFalse(viewModel.showPrecisionCircleOnMap.value)
}
@Test
fun setLastHeardFilter_updatesStateAndPrefs() {
viewModel.setLastHeardFilter(LastHeardFilter.OneHour)
assertEquals(LastHeardFilter.OneHour, viewModel.lastHeardFilter.value)
assertEquals(3600L, mapPrefs.lastHeardFilter.value)
}
@Test
fun setLastHeardTrackFilter_updatesStateAndPrefs() {
viewModel.setLastHeardTrackFilter(LastHeardFilter.OneDay)
assertEquals(LastHeardFilter.OneDay, viewModel.lastHeardTrackFilter.value)
assertEquals(86400L, mapPrefs.lastHeardTrackFilter.value)
}
// ---- MapFilterState composition ----
@Test
fun mapFilterState_reflectsAllFilterValues() = runTest(testDispatcher) {
viewModel.mapFilterStateFlow.test {
val initial = awaitItem()
assertFalse(initial.onlyFavorites)
assertTrue(initial.showWaypoints)
assertTrue(initial.showPrecisionCircle)
assertEquals(LastHeardFilter.Any, initial.lastHeardFilter)
viewModel.toggleOnlyFavorites()
val updated = awaitItem()
assertTrue(updated.onlyFavorites)
cancelAndIgnoreRemainingEvents()
}
}
// ---- filteredNodes tests ----
@Test
fun filteredNodes_noFilters_returnsAllNodesWithPosition() = runTest(testDispatcher) {
val nodes = listOf(nodeWithPosition(1), nodeWithPosition(2), nodeWithPosition(3))
nodeRepository.setNodes(nodes)
viewModel.filteredNodes.test {
val result = expectMostRecentItem()
assertEquals(3, result.size)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun filteredNodes_favoritesFilter_showsOnlyFavoritesAndMyNode() = runTest(testDispatcher) {
val myNodeNum = 1
nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum))
val nodes =
listOf(
nodeWithPosition(myNodeNum),
nodeWithPosition(2, isFavorite = true),
nodeWithPosition(3, isFavorite = false),
)
nodeRepository.setNodes(nodes)
viewModel.toggleOnlyFavorites()
viewModel.filteredNodes.test {
val result = expectMostRecentItem()
val nodeNums = result.map { it.num }.toSet()
// My node (1) + favorite node (2) should be present; non-favorite (3) filtered out
assertTrue(myNodeNum in nodeNums, "My node should always be visible")
assertTrue(2 in nodeNums, "Favorite node should be visible")
assertFalse(3 in nodeNums, "Non-favorite node should be filtered out")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun filteredNodes_lastHeardFilter_excludesStaleNodes() = runTest(testDispatcher) {
val now = nowSeconds.toInt()
val nodes =
listOf(
nodeWithPosition(1, lastHeard = now), // heard just now
nodeWithPosition(2, lastHeard = now - 7200), // heard 2 hours ago
)
nodeRepository.setNodes(nodes)
viewModel.setLastHeardFilter(LastHeardFilter.OneHour)
viewModel.filteredNodes.test {
val result = expectMostRecentItem()
val nodeNums = result.map { it.num }.toSet()
assertTrue(1 in nodeNums, "Recently heard node should be visible")
assertFalse(2 in nodeNums, "Stale node should be filtered out with 1-hour filter")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun filteredNodes_anyFilter_showsAllNodes() = runTest(testDispatcher) {
val now = nowSeconds.toInt()
val nodes =
listOf(
nodeWithPosition(1, lastHeard = now),
nodeWithPosition(2, lastHeard = now - 200000), // very old
)
nodeRepository.setNodes(nodes)
viewModel.setLastHeardFilter(LastHeardFilter.Any)
viewModel.filteredNodes.test {
val result = expectMostRecentItem()
assertEquals(2, result.size, "Any filter should show all nodes")
cancelAndIgnoreRemainingEvents()
}
}
// ---- getNodeOrFallback ----
@Test
fun getNodeOrFallback_existingNode_returnsNode() {
val testNode = TestDataFactory.createTestNode(num = 42, longName = "Found")
nodeRepository.setNodes(listOf(testNode))
val result = viewModel.getNodeOrFallback(42)
assertEquals(42, result.num)
assertEquals("Found", result.user.long_name)
}
@Test
fun getNodeOrFallback_missingNode_returnsFallback() {
val result = viewModel.getNodeOrFallback(9999)
assertEquals(9999, result.num)
}
}

View File

@@ -23,6 +23,7 @@ import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
@@ -35,6 +36,7 @@ import org.meshtastic.core.testing.FakeMapPrefs
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.proto.Waypoint
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -48,6 +50,7 @@ class MapViewModelTest {
private lateinit var viewModel: MapViewModel
private lateinit var mapCameraPrefs: FakeMapCameraPrefs
private lateinit var mapPrefs: FakeMapPrefs
private lateinit var radioController: FakeRadioController
private val packetRepository: PacketRepository = mock()
@BeforeTest
@@ -55,6 +58,7 @@ class MapViewModelTest {
Dispatchers.setMain(testDispatcher)
mapCameraPrefs = FakeMapCameraPrefs()
mapPrefs = FakeMapPrefs()
radioController = FakeRadioController()
every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList())
viewModel = createViewModel()
}
@@ -64,12 +68,15 @@ class MapViewModelTest {
Dispatchers.resetMain()
}
private fun createViewModel(savedStateHandle: SavedStateHandle = SavedStateHandle()): MapViewModel = MapViewModel(
private fun createViewModel(
savedStateHandle: SavedStateHandle = SavedStateHandle(),
nodeRepository: FakeNodeRepository = FakeNodeRepository(),
): MapViewModel = MapViewModel(
mapPrefs = mapPrefs,
mapCameraPrefs = mapCameraPrefs,
nodeRepository = FakeNodeRepository(),
nodeRepository = nodeRepository,
packetRepository = packetRepository,
radioController = FakeRadioController(),
radioController = radioController,
savedStateHandle = savedStateHandle,
)
@@ -165,13 +172,6 @@ class MapViewModelTest {
}
}
@Test
fun compassBearingReflectsPrefs() {
mapCameraPrefs.setCameraBearing(180f)
val vm = createViewModel()
assertEquals(180f, vm.compassBearing)
}
@Test
fun blankStyleUriFallsBackToOpenStreetMap() = runTest(testDispatcher) {
// selectedStyleUri defaults to "" in FakeMapCameraPrefs
@@ -181,4 +181,104 @@ class MapViewModelTest {
cancelAndIgnoreRemainingEvents()
}
}
// ---- createAndSendWaypoint tests ----
@Test
fun createAndSendWaypoint_newWaypoint_convertsPositionToIntCoordinates() = runTest(testDispatcher) {
val position = org.maplibre.spatialk.geojson.Position(longitude = -74.0, latitude = 40.0)
viewModel.createAndSendWaypoint(
name = "Test WP",
description = "A waypoint",
icon = 0x1F4CD,
locked = false,
expire = 0,
existingWaypoint = null,
position = position,
)
// sendWaypoint dispatches to ioDispatcher; give it time to execute
delay(100)
// FakeRadioController.getPacketId() returns 1, and sendMessage appends to sentPackets
assertEquals(1, radioController.sentPackets.size)
val sent = radioController.sentPackets.first()
val wpt = sent.waypoint!!
assertEquals("Test WP", wpt.name)
assertEquals("A waypoint", wpt.description)
assertEquals(0x1F4CD, wpt.icon)
assertEquals(0, wpt.locked_to)
// 40.0 / 1e-7 = 400000000
assertEquals(400000000, wpt.latitude_i)
// -74.0 / 1e-7 = -740000000
assertEquals(-740000000, wpt.longitude_i)
}
@Test
fun createAndSendWaypoint_editExisting_retainsOriginalCoordinates() = runTest(testDispatcher) {
val existing = Waypoint(id = 42, name = "Old Name", latitude_i = 515000000, longitude_i = -1000000)
viewModel.createAndSendWaypoint(
name = "New Name",
description = "Updated",
icon = 0x1F3E0,
locked = false,
expire = 0,
existingWaypoint = existing,
position = null,
)
delay(100)
assertEquals(1, radioController.sentPackets.size)
val wpt = radioController.sentPackets.first().waypoint!!
assertEquals(42, wpt.id) // Retains existing ID
assertEquals("New Name", wpt.name)
assertEquals(515000000, wpt.latitude_i) // Retains existing coords
assertEquals(-1000000, wpt.longitude_i)
}
@Test
fun createAndSendWaypoint_locked_setsLockedToMyNodeNum() = runTest(testDispatcher) {
val nodeRepository = FakeNodeRepository()
nodeRepository.setMyNodeInfo(org.meshtastic.core.testing.TestDataFactory.createMyNodeInfo(myNodeNum = 99))
val vm = createViewModel(nodeRepository = nodeRepository)
val position = org.maplibre.spatialk.geojson.Position(longitude = 0.1, latitude = 0.1)
vm.createAndSendWaypoint(
name = "Locked WP",
description = "",
icon = 0,
locked = true,
expire = 0,
existingWaypoint = null,
position = position,
)
delay(100)
assertEquals(1, radioController.sentPackets.size)
assertEquals(99, radioController.sentPackets.first().waypoint!!.locked_to)
}
@Test
fun createAndSendWaypoint_noPositionNoExisting_usesZeroCoordinates() = runTest(testDispatcher) {
viewModel.createAndSendWaypoint(
name = "Nowhere",
description = "",
icon = 0,
locked = false,
expire = 0,
existingWaypoint = null,
position = null,
)
delay(100)
assertEquals(1, radioController.sentPackets.size)
val wpt = radioController.sentPackets.first().waypoint!!
assertEquals(0, wpt.latitude_i)
assertEquals(0, wpt.longitude_i)
}
}

View File

@@ -0,0 +1,275 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.util
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@Suppress("MagicNumber")
class GeoJsonConvertersTest {
// --- nodesToFeatureCollection ---
@Test
fun nodesToFeatureCollection_emptyList_returnsEmptyCollection() {
val result = nodesToFeatureCollection(emptyList())
assertTrue(result.features.isEmpty())
}
@Test
fun nodesToFeatureCollection_skipsNodesWithoutPosition() {
val node = Node(num = 1, position = Position())
val result = nodesToFeatureCollection(listOf(node))
assertTrue(result.features.isEmpty())
}
@Test
fun nodesToFeatureCollection_skipsZeroLatLng() {
val node = Node(num = 1, position = Position(latitude_i = 0, longitude_i = 0))
val result = nodesToFeatureCollection(listOf(node))
assertTrue(result.features.isEmpty())
}
@Test
fun nodesToFeatureCollection_convertsValidNode() {
val node =
Node(
num = 42,
user = User(short_name = "AB", long_name = "Alpha Bravo"),
position = Position(latitude_i = 400000000, longitude_i = -740000000),
lastHeard = 1000,
isFavorite = true,
hopsAway = 2,
viaMqtt = false,
snr = 5.5f,
rssi = -80,
)
val result = nodesToFeatureCollection(listOf(node), myNodeNum = 42)
assertEquals(1, result.features.size)
val feature = result.features.first()
val coords = feature.geometry.coordinates
assertEquals(40.0, coords.latitude, 0.001)
assertEquals(-74.0, coords.longitude, 0.001)
val props = feature.properties!!
assertEquals(42, props["node_num"]?.toString()?.toIntOrNull())
assertEquals("\"AB\"", props["short_name"].toString())
assertEquals("\"Alpha Bravo\"", props["long_name"].toString())
assertEquals("true", props["is_favorite"].toString())
assertEquals("true", props["is_my_node"].toString())
}
@Test
fun nodesToFeatureCollection_isMyNodeFalseForOtherNodes() {
val node = Node(num = 10, position = Position(latitude_i = 400000000, longitude_i = -740000000))
val result = nodesToFeatureCollection(listOf(node), myNodeNum = 42)
val props = result.features.first().properties!!
assertEquals("false", props["is_my_node"].toString())
}
@Test
fun nodesToFeatureCollection_multipleNodes() {
val nodes =
listOf(
Node(num = 1, position = Position(latitude_i = 100000000, longitude_i = 200000000)),
Node(num = 2, position = Position(latitude_i = 300000000, longitude_i = 400000000)),
)
val result = nodesToFeatureCollection(nodes)
assertEquals(2, result.features.size)
}
// --- waypointsToFeatureCollection ---
@Test
fun waypointsToFeatureCollection_emptyMap_returnsEmptyCollection() {
val result = waypointsToFeatureCollection(emptyMap())
assertTrue(result.features.isEmpty())
}
@Test
fun waypointsToFeatureCollection_skipsZeroLatLng() {
val waypoint = Waypoint(id = 1, latitude_i = 0, longitude_i = 0, name = "Test")
val packet = DataPacket("dest", 0, waypoint)
val result = waypointsToFeatureCollection(mapOf(1 to packet))
assertTrue(result.features.isEmpty())
}
@Test
fun waypointsToFeatureCollection_convertsValidWaypoint() {
val waypoint =
Waypoint(
id = 99,
name = "Home",
description = "My house",
icon = 0x1F3E0, // House emoji
locked_to = 42,
latitude_i = 515000000,
longitude_i = -1000000,
expire = 0,
)
val packet = DataPacket("dest", 0, waypoint)
val result = waypointsToFeatureCollection(mapOf(99 to packet))
assertEquals(1, result.features.size)
val feature = result.features.first()
val coords = feature.geometry.coordinates
assertEquals(51.5, coords.latitude, 0.001)
assertEquals(-0.1, coords.longitude, 0.001)
val props = feature.properties!!
assertEquals(99, props["waypoint_id"]?.toString()?.toIntOrNull())
assertEquals("\"Home\"", props["name"].toString())
}
// --- positionsToLineString ---
@Test
fun positionsToLineString_lessThanTwoPositions_returnsEmptyCollection() {
val result = positionsToLineString(listOf(Position(latitude_i = 100000000, longitude_i = 200000000)))
assertTrue(result.features.isEmpty())
}
@Test
fun positionsToLineString_emptyList_returnsEmptyCollection() {
val result = positionsToLineString(emptyList())
assertTrue(result.features.isEmpty())
}
@Test
fun positionsToLineString_validPositions_createsLineString() {
val positions =
listOf(
Position(latitude_i = 100000000, longitude_i = 200000000),
Position(latitude_i = 110000000, longitude_i = 210000000),
Position(latitude_i = 120000000, longitude_i = 220000000),
)
val result = positionsToLineString(positions)
assertEquals(1, result.features.size)
}
@Test
fun positionsToLineString_skipsZeroCoords() {
val positions =
listOf(
Position(latitude_i = 100000000, longitude_i = 200000000),
Position(latitude_i = 0, longitude_i = 0),
Position(latitude_i = 120000000, longitude_i = 220000000),
)
val result = positionsToLineString(positions)
assertEquals(1, result.features.size)
}
// --- positionsToPointFeatures ---
@Test
fun positionsToPointFeatures_emptyList_returnsEmptyCollection() {
val result = positionsToPointFeatures(emptyList())
assertTrue(result.features.isEmpty())
}
@Test
fun positionsToPointFeatures_convertsValidPositions() {
val positions = listOf(Position(latitude_i = 400000000, longitude_i = -740000000, time = 1000, altitude = 100))
val result = positionsToPointFeatures(positions)
assertEquals(1, result.features.size)
val props = result.features.first().properties!!
assertEquals("\"1000\"", props["time"].toString())
assertEquals(100, props["altitude"]?.toString()?.toIntOrNull())
}
// --- precisionBitsToMeters ---
@Test
fun precisionBitsToMeters_knownValues() {
assertEquals(5886.0, precisionBitsToMeters(10))
assertEquals(2944.0, precisionBitsToMeters(11))
assertEquals(1472.0, precisionBitsToMeters(12))
assertEquals(736.0, precisionBitsToMeters(13))
assertEquals(368.0, precisionBitsToMeters(14))
assertEquals(184.0, precisionBitsToMeters(15))
assertEquals(92.0, precisionBitsToMeters(16))
assertEquals(46.0, precisionBitsToMeters(17))
assertEquals(23.0, precisionBitsToMeters(18))
assertEquals(11.5, precisionBitsToMeters(19))
}
@Test
fun precisionBitsToMeters_outOfRange_returnsZero() {
assertEquals(0.0, precisionBitsToMeters(0))
assertEquals(0.0, precisionBitsToMeters(9))
assertEquals(0.0, precisionBitsToMeters(20))
assertEquals(0.0, precisionBitsToMeters(-1))
}
// --- intToHexColor ---
@Test
fun intToHexColor_basicColors() {
assertEquals("#FF0000", intToHexColor(0xFFFF0000.toInt())) // Red
assertEquals("#00FF00", intToHexColor(0xFF00FF00.toInt())) // Green
assertEquals("#0000FF", intToHexColor(0xFF0000FF.toInt())) // Blue
assertEquals("#000000", intToHexColor(0xFF000000.toInt())) // Black
assertEquals("#FFFFFF", intToHexColor(0xFFFFFFFF.toInt())) // White
}
@Test
fun intToHexColor_stripsAlpha() {
// Alpha channel should be stripped — only RGB remains
assertEquals("#6750A4", intToHexColor(0xFF6750A4.toInt()))
assertEquals("#6750A4", intToHexColor(0x006750A4))
}
@Test
fun intToHexColor_padsSixDigits() {
assertEquals("#000001", intToHexColor(1))
assertEquals("#000100", intToHexColor(0x100))
}
// --- convertIntToEmoji ---
@Test
fun convertIntToEmoji_bmpCharacter() {
// 0x2764 = Heart character (❤)
assertEquals("\u2764", convertIntToEmoji(0x2764))
}
@Test
fun convertIntToEmoji_supplementaryCharacter() {
// 0x1F4CD = Round Pushpin (📍)
assertEquals("\uD83D\uDCCD", convertIntToEmoji(0x1F4CD))
}
@Test
fun convertIntToEmoji_houseEmoji() {
// 0x1F3E0 = House (🏠)
val result = convertIntToEmoji(0x1F3E0)
assertEquals(2, result.length) // Surrogate pair
}
@Test
fun convertIntToEmoji_maxBmpCharacter() {
val result = convertIntToEmoji(0xFFFF)
assertEquals(1, result.length)
}
}