mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-14 02:49:56 -04:00
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:
@@ -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())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ?: "",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user