From 5ad55fcbce02c2cf328f7a93b4e48b54d3d52a2a Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 13:04:02 -0500 Subject: [PATCH] =?UTF-8?q?refactor(map):=20architectural=20improvements?= =?UTF-8?q?=20=E2=80=94=20DRY,=20UDF,=20dead=20code,=20test=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../feature/map/BaseMapViewModel.kt | 14 + .../org/meshtastic/feature/map/MapScreen.kt | 52 +--- .../meshtastic/feature/map/MapViewModel.kt | 35 ++- .../feature/map/component/InlineMap.kt | 2 +- .../feature/map/component/NodeTrackMap.kt | 2 +- .../feature/map/component/TracerouteLayers.kt | 2 +- .../feature/map/component/TracerouteMap.kt | 9 +- .../feature/map/util/GeoJsonConverters.kt | 3 - .../feature/map/util/MapConstants.kt | 20 ++ .../feature/map/BaseMapViewModelTest.kt | 213 ++++++++++++-- .../feature/map/MapViewModelTest.kt | 120 +++++++- .../feature/map/util/GeoJsonConvertersTest.kt | 275 ++++++++++++++++++ 12 files changed, 664 insertions(+), 83 deletions(-) create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index a1a31dbf4..57efc61c7 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -192,6 +192,20 @@ open class BaseMapViewModel( lastHeardTrackFilter.value, ), ) + + /** Nodes with position, filtered by favorites and last-heard preferences. */ + val filteredNodes: StateFlow> = + 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()) } /** diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index 4fa57f01d..80002f385 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -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 ?: "", diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt index f05502127..60e5df0cd 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -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) + } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt index 70d7f4874..b13af0cd3 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -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 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt index c919b2afa..5ea9b5ef6 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt @@ -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 /** diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt index a386cc7c6..45369590e 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt @@ -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 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt index 7dbb9b029..4f1a69309 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt @@ -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, onMappableCountChanged: (shown: Int, total: Int) -> Unit, modifier: Modifier = Modifier, + nodes: Map = 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, ) } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt index e111ad10a..cbff63e81 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt @@ -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 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt new file mode 100644 index 000000000..cdecf8ecd --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt @@ -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 . + */ +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 diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt index ce6109e26..fea1de0e0 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt @@ -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) + } } diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt index 7981ab1df..a875d9e2a 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt @@ -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) + } } diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt new file mode 100644 index 000000000..5ce345214 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt @@ -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 . + */ +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) + } +}