mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(metrics/map): DRY up charts, decompose MapView monoliths, add test coverage (#5049)
This commit is contained in:
parent
56332f4d77
commit
520fa717a9
71 changed files with 3464 additions and 2169 deletions
|
|
@ -57,7 +57,6 @@ fun MapScreen(
|
|||
) { paddingValues ->
|
||||
LocalMapViewProvider.current?.MapView(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
viewModel = viewModel,
|
||||
navigateToNodeDetails = navigateToNodeDetails,
|
||||
waypointId = waypointId,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue