refactor(metrics/map): DRY up charts, decompose MapView monoliths, add test coverage (#5049)

This commit is contained in:
James Rich 2026-04-10 15:54:09 -05:00 committed by GitHub
parent 56332f4d77
commit 520fa717a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 3464 additions and 2169 deletions

View file

@ -57,7 +57,6 @@ fun MapScreen(
) { paddingValues ->
LocalMapViewProvider.current?.MapView(
modifier = Modifier.fillMaxSize().padding(paddingValues),
viewModel = viewModel,
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
)

View file

@ -31,6 +31,7 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@ -41,7 +42,6 @@ import org.meshtastic.core.resources.one_day
import org.meshtastic.core.resources.one_hour
import org.meshtastic.core.resources.two_days
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
@ -194,16 +194,42 @@ open class BaseMapViewModel(
)
}
/**
* Result of resolving a [TracerouteOverlay]'s node nums into displayable [Node] instances.
*
* @property overlayNodeNums All unique node nums referenced by the traceroute.
* @property nodesForMarkers Nodes to render as map markers (with snapshot positions when available).
* @property nodeLookup Node-num-keyed map for polyline coordinate resolution.
*/
data class TracerouteNodeSelection(
val overlayNodeNums: Set<Int>,
val nodesForMarkers: List<Node>,
val nodeLookup: Map<Int, Node>,
)
/** Convenience extension that delegates to [tracerouteNodeSelection] using the VM's [getNodeOrFallback]. */
fun BaseMapViewModel.tracerouteNodeSelection(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
nodes: List<Node>,
): TracerouteNodeSelection = tracerouteNodeSelection(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
nodes = nodes,
getNodeOrFallback = ::getNodeOrFallback,
)
/**
* Resolves traceroute overlay node nums into displayable [Node] instances. Snapshot positions (recorded at traceroute
* time) take priority over live positions from the node database.
*
* @param getNodeOrFallback Provides a [Node] for a given num, falling back to a stub if not in the DB.
*/
fun tracerouteNodeSelection(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
nodes: List<Node>,
getNodeOrFallback: (Int) -> Node,
): TracerouteNodeSelection {
val overlayNodeNums = tracerouteOverlay?.relatedNodeNums ?: emptySet()
val tracerouteSnapshotNodes =

View file

@ -1,28 +0,0 @@
/*
* Copyright (c) 2025-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.model
data class TracerouteOverlay(
val requestId: Int,
val forwardRoute: List<Int> = emptyList(),
val returnRoute: List<Int> = emptyList(),
) {
val relatedNodeNums: Set<Int> = (forwardRoute + returnRoute).toSet()
val hasRoutes: Boolean
get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty()
}

View file

@ -0,0 +1,49 @@
/*
* 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
import kotlin.test.Test
import kotlin.test.assertEquals
@Suppress("MagicNumber")
class LastHeardFilterTest {
@Test
fun fromSeconds_knownValues() {
assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(0L))
assertEquals(LastHeardFilter.OneHour, LastHeardFilter.fromSeconds(3600L))
assertEquals(LastHeardFilter.EightHours, LastHeardFilter.fromSeconds(28800L))
assertEquals(LastHeardFilter.OneDay, LastHeardFilter.fromSeconds(86400L))
assertEquals(LastHeardFilter.TwoDays, LastHeardFilter.fromSeconds(172800L))
}
@Test
fun fromSeconds_unknownValue_defaultsToAny() {
assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(9999L))
assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(-1L))
assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(Long.MAX_VALUE))
}
@Test
fun seconds_matchExpectedValues() {
assertEquals(0L, LastHeardFilter.Any.seconds)
assertEquals(3600L, LastHeardFilter.OneHour.seconds)
assertEquals(28800L, LastHeardFilter.EightHours.seconds)
assertEquals(86400L, LastHeardFilter.OneDay.seconds)
assertEquals(172800L, LastHeardFilter.TwoDays.seconds)
}
}

View file

@ -0,0 +1,214 @@
/*
* 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
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.proto.Position
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class TracerouteNodeSelectionTest {
private fun nodeWithPosition(num: Int, latI: Int = num * 100000, lonI: Int = num * 200000): Node =
Node(num = num, position = Position(latitude_i = latI, longitude_i = lonI))
private fun nodeWithoutPosition(num: Int): Node = Node(num = num, position = Position())
private val defaultGetNodeOrFallback: (Int) -> Node = { num -> Node(num = num) }
// ---- Null overlay (no traceroute active) ----
@Test
fun nullOverlay_returnsAllNodesUnfiltered() {
val nodes = listOf(nodeWithPosition(1), nodeWithPosition(2), nodeWithPosition(3))
val result =
tracerouteNodeSelection(
tracerouteOverlay = null,
tracerouteNodePositions = emptyMap(),
nodes = nodes,
getNodeOrFallback = defaultGetNodeOrFallback,
)
assertEquals(emptySet(), result.overlayNodeNums)
assertEquals(3, result.nodesForMarkers.size)
assertEquals(nodes.map { it.num }.toSet(), result.nodesForMarkers.map { it.num }.toSet())
}
@Test
fun nullOverlay_nodeLookupContainsOnlyNodesWithValidPositions() {
val nodes = listOf(nodeWithPosition(1), nodeWithoutPosition(2), nodeWithPosition(3))
val result =
tracerouteNodeSelection(
tracerouteOverlay = null,
tracerouteNodePositions = emptyMap(),
nodes = nodes,
getNodeOrFallback = defaultGetNodeOrFallback,
)
// nodeLookup filters to validPosition nodes when no snapshot
assertEquals(setOf(1, 3), result.nodeLookup.keys)
}
// ---- Overlay with snapshot positions ----
@Test
fun overlayWithSnapshot_usesSnapshotPositions() {
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20), returnRoute = listOf(20, 10))
val snapshotPositions =
mapOf(
10 to Position(latitude_i = 400000000, longitude_i = -700000000),
20 to Position(latitude_i = 410000000, longitude_i = -710000000),
)
val liveNodes =
listOf(
nodeWithPosition(10, latI = 100000000, lonI = -100000000),
nodeWithPosition(20, latI = 200000000, lonI = -200000000),
nodeWithPosition(30),
)
val result =
tracerouteNodeSelection(
tracerouteOverlay = overlay,
tracerouteNodePositions = snapshotPositions,
nodes = liveNodes,
getNodeOrFallback = { num -> liveNodes.find { it.num == num } ?: Node(num = num) },
)
// Should use snapshot positions, not live ones
assertEquals(setOf(10, 20), result.overlayNodeNums)
assertEquals(2, result.nodesForMarkers.size)
assertEquals(400000000, result.nodesForMarkers.first { it.num == 10 }.position.latitude_i)
assertEquals(410000000, result.nodesForMarkers.first { it.num == 20 }.position.latitude_i)
}
@Test
fun overlayWithSnapshot_nodeLookupUsesSnapshotNodes() {
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20))
val snapshotPositions =
mapOf(
10 to Position(latitude_i = 400000000, longitude_i = -700000000),
20 to Position(latitude_i = 410000000, longitude_i = -710000000),
)
val result =
tracerouteNodeSelection(
tracerouteOverlay = overlay,
tracerouteNodePositions = snapshotPositions,
nodes = emptyList(),
getNodeOrFallback = { num -> Node(num = num) },
)
assertEquals(2, result.nodeLookup.size)
assertEquals(400000000, result.nodeLookup[10]?.position?.latitude_i)
}
@Test
fun overlayWithSnapshot_filtersToOverlayNodes() {
// Snapshot has node 30 which is NOT in the overlay routes
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20))
val snapshotPositions =
mapOf(
10 to Position(latitude_i = 400000000, longitude_i = -700000000),
20 to Position(latitude_i = 410000000, longitude_i = -710000000),
30 to Position(latitude_i = 420000000, longitude_i = -720000000),
)
val result =
tracerouteNodeSelection(
tracerouteOverlay = overlay,
tracerouteNodePositions = snapshotPositions,
nodes = emptyList(),
getNodeOrFallback = { num -> Node(num = num) },
)
// nodesForMarkers should only contain nodes in the overlay (10, 20), not 30
assertEquals(setOf(10, 20), result.nodesForMarkers.map { it.num }.toSet())
// but nodeLookup has all snapshot nodes (for polyline drawing)
assertEquals(3, result.nodeLookup.size)
}
// ---- Overlay without snapshot positions (live fallback) ----
@Test
fun overlayWithoutSnapshot_filtersLiveNodesToOverlayNums() {
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20), returnRoute = listOf(30))
val liveNodes = listOf(nodeWithPosition(10), nodeWithPosition(20), nodeWithPosition(30), nodeWithPosition(40))
val result =
tracerouteNodeSelection(
tracerouteOverlay = overlay,
tracerouteNodePositions = emptyMap(),
nodes = liveNodes,
getNodeOrFallback = defaultGetNodeOrFallback,
)
assertEquals(setOf(10, 20, 30), result.overlayNodeNums)
assertEquals(setOf(10, 20, 30), result.nodesForMarkers.map { it.num }.toSet())
}
@Test
fun overlayWithoutSnapshot_nodeLookupFiltersToValidPositions() {
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20))
val liveNodes = listOf(nodeWithPosition(10), nodeWithoutPosition(20))
val result =
tracerouteNodeSelection(
tracerouteOverlay = overlay,
tracerouteNodePositions = emptyMap(),
nodes = liveNodes,
getNodeOrFallback = defaultGetNodeOrFallback,
)
// nodeLookup only includes nodes with validPosition
assertEquals(setOf(10), result.nodeLookup.keys)
}
// ---- Edge cases ----
@Test
fun emptyOverlayRoutes_yieldsEmptySelection() {
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = emptyList(), returnRoute = emptyList())
val liveNodes = listOf(nodeWithPosition(10), nodeWithPosition(20))
val result =
tracerouteNodeSelection(
tracerouteOverlay = overlay,
tracerouteNodePositions = emptyMap(),
nodes = liveNodes,
getNodeOrFallback = defaultGetNodeOrFallback,
)
assertTrue(result.overlayNodeNums.isEmpty())
assertTrue(result.nodesForMarkers.isEmpty())
}
@Test
fun getNodeOrFallback_usedForSnapshotNodeLookup() {
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10))
val snapshotPositions = mapOf(10 to Position(latitude_i = 400000000, longitude_i = -700000000))
var lookupCalledWith: Int? = null
val result =
tracerouteNodeSelection(
tracerouteOverlay = overlay,
tracerouteNodePositions = snapshotPositions,
nodes = emptyList(),
getNodeOrFallback = { num ->
lookupCalledWith = num
Node(num = num)
},
)
assertEquals(10, lookupCalledWith)
assertEquals(1, result.nodesForMarkers.size)
}
}

View file

@ -16,6 +16,7 @@
*/
package org.meshtastic.feature.map.model
import org.meshtastic.core.model.TracerouteOverlay
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse