test(discovery): add DAO, packet collection, history, and deep-link tests (D010, D023, D042)

This commit is contained in:
James Rich
2026-05-07 19:32:38 -05:00
parent 92bf9a6a31
commit cffafb175d
5 changed files with 1020 additions and 3 deletions

View File

@@ -0,0 +1,302 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.core.database.dao
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.core.database.getInMemoryDatabaseBuilder
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
abstract class CommonDiscoveryDaoTest {
private lateinit var database: MeshtasticDatabase
private lateinit var dao: DiscoveryDao
suspend fun createDb() {
database = getInMemoryDatabaseBuilder().build()
dao = database.discoveryDao()
}
@AfterTest
fun closeDb() {
database.close()
}
// region Session CRUD
@Test
fun insertSession_returnsAutoGeneratedId() = runTest {
val session = testSession(timestamp = 1_000_000L)
val id = dao.insertSession(session)
assertTrue(id > 0, "Auto-generated id should be > 0")
}
@Test
fun getSession_returnsInsertedSession() = runTest {
val id = dao.insertSession(testSession(timestamp = 2_000_000L, homePreset = "MEDIUM_SLOW"))
val loaded = dao.getSession(id)
assertNotNull(loaded)
assertEquals(id, loaded.id)
assertEquals("MEDIUM_SLOW", loaded.homePreset)
assertEquals(2_000_000L, loaded.timestamp)
}
@Test fun getSession_returnsNullForMissing() = runTest { assertNull(dao.getSession(999L)) }
@Test
fun updateSession_modifiesExistingRow() = runTest {
val id = dao.insertSession(testSession(timestamp = 3_000_000L))
val original = dao.getSession(id)!!
dao.updateSession(original.copy(completionStatus = "stopped", totalUniqueNodes = 5))
val updated = dao.getSession(id)!!
assertEquals("stopped", updated.completionStatus)
assertEquals(5, updated.totalUniqueNodes)
}
@Test
fun deleteSession_removesRow() = runTest {
val id = dao.insertSession(testSession())
dao.deleteSession(id)
assertNull(dao.getSession(id))
}
// endregion
// region Session sort order (getAllSessions returns newest-first)
@Test
fun getAllSessions_orderedByTimestampDescending() = runTest {
dao.insertSession(testSession(timestamp = 100L))
dao.insertSession(testSession(timestamp = 300L))
dao.insertSession(testSession(timestamp = 200L))
val sessions = dao.getAllSessions().first()
assertEquals(3, sessions.size)
assertEquals(300L, sessions[0].timestamp)
assertEquals(200L, sessions[1].timestamp)
assertEquals(100L, sessions[2].timestamp)
}
// endregion
// region Preset result relation loading
@Test
fun getPresetResults_returnsResultsForSession() = runTest {
val sessionId = dao.insertSession(testSession())
dao.insertPresetResult(testPresetResult(sessionId, presetName = "LONG_FAST"))
dao.insertPresetResult(testPresetResult(sessionId, presetName = "SHORT_FAST"))
val results = dao.getPresetResults(sessionId)
assertEquals(2, results.size)
assertTrue(results.any { it.presetName == "LONG_FAST" })
assertTrue(results.any { it.presetName == "SHORT_FAST" })
}
@Test
fun getPresetResults_doesNotReturnOtherSessionResults() = runTest {
val session1 = dao.insertSession(testSession(timestamp = 1L))
val session2 = dao.insertSession(testSession(timestamp = 2L))
dao.insertPresetResult(testPresetResult(session1, presetName = "A"))
dao.insertPresetResult(testPresetResult(session2, presetName = "B"))
val results = dao.getPresetResults(session1)
assertEquals(1, results.size)
assertEquals("A", results[0].presetName)
}
@Test
fun getPresetResultsFlow_emitsOnInsert() = runTest {
val sessionId = dao.insertSession(testSession())
val initial = dao.getPresetResultsFlow(sessionId).first()
assertTrue(initial.isEmpty())
dao.insertPresetResult(testPresetResult(sessionId, presetName = "LONG_FAST"))
val updated = dao.getPresetResultsFlow(sessionId).first()
assertEquals(1, updated.size)
}
// endregion
// region Discovered node relation loading
@Test
fun getDiscoveredNodes_returnsNodesForPresetResult() = runTest {
val sessionId = dao.insertSession(testSession())
val presetId = dao.insertPresetResult(testPresetResult(sessionId))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 100))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 200))
val nodes = dao.getDiscoveredNodes(presetId)
assertEquals(2, nodes.size)
}
@Test
fun insertDiscoveredNodes_batchInsert() = runTest {
val sessionId = dao.insertSession(testSession())
val presetId = dao.insertPresetResult(testPresetResult(sessionId))
val batch =
listOf(testNode(presetId, nodeNum = 1), testNode(presetId, nodeNum = 2), testNode(presetId, nodeNum = 3))
dao.insertDiscoveredNodes(batch)
assertEquals(3, dao.getDiscoveredNodes(presetId).size)
}
@Test
fun updateDiscoveredNode_modifiesExistingRow() = runTest {
val sessionId = dao.insertSession(testSession())
val presetId = dao.insertPresetResult(testPresetResult(sessionId))
val nodeId = dao.insertDiscoveredNode(testNode(presetId, nodeNum = 42))
val original = dao.getDiscoveredNodes(presetId).first { it.id == nodeId }
dao.updateDiscoveredNode(original.copy(snr = 12.5f, rssi = -55))
val updated = dao.getDiscoveredNodes(presetId).first { it.id == nodeId }
assertEquals(12.5f, updated.snr)
assertEquals(-55, updated.rssi)
}
// endregion
// region Cascade deletion
@Test
fun deleteSession_cascadesPresetResults() = runTest {
val sessionId = dao.insertSession(testSession())
dao.insertPresetResult(testPresetResult(sessionId, presetName = "LONG_FAST"))
dao.insertPresetResult(testPresetResult(sessionId, presetName = "SHORT_FAST"))
dao.deleteSession(sessionId)
assertTrue(dao.getPresetResults(sessionId).isEmpty(), "Preset results should be cascade-deleted")
}
@Test
fun deleteSession_cascadesDiscoveredNodes() = runTest {
val sessionId = dao.insertSession(testSession())
val presetId = dao.insertPresetResult(testPresetResult(sessionId))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 1))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 2))
dao.deleteSession(sessionId)
assertTrue(dao.getDiscoveredNodes(presetId).isEmpty(), "Discovered nodes should be cascade-deleted")
}
@Test
fun deleteSession_doesNotAffectOtherSessions() = runTest {
val session1 = dao.insertSession(testSession(timestamp = 1L))
val session2 = dao.insertSession(testSession(timestamp = 2L))
val preset1 = dao.insertPresetResult(testPresetResult(session1))
val preset2 = dao.insertPresetResult(testPresetResult(session2))
dao.insertDiscoveredNode(testNode(preset1, nodeNum = 1))
dao.insertDiscoveredNode(testNode(preset2, nodeNum = 2))
dao.deleteSession(session1)
assertNotNull(dao.getSession(session2))
assertEquals(1, dao.getPresetResults(session2).size)
assertEquals(1, dao.getDiscoveredNodes(preset2).size)
}
// endregion
// region Aggregate queries
@Test
fun getUniqueNodeCount_countsAcrossPresets() = runTest {
val sessionId = dao.insertSession(testSession())
val preset1 = dao.insertPresetResult(testPresetResult(sessionId, presetName = "A"))
val preset2 = dao.insertPresetResult(testPresetResult(sessionId, presetName = "B"))
// Same node 100 appears in both presets
dao.insertDiscoveredNode(testNode(preset1, nodeNum = 100))
dao.insertDiscoveredNode(testNode(preset1, nodeNum = 200))
dao.insertDiscoveredNode(testNode(preset2, nodeNum = 100))
dao.insertDiscoveredNode(testNode(preset2, nodeNum = 300))
assertEquals(3, dao.getUniqueNodeCount(sessionId), "Node 100 appears in both presets but should count once")
}
@Test
fun getUniqueNodeNums_returnsDistinctNodeNums() = runTest {
val sessionId = dao.insertSession(testSession())
val presetId = dao.insertPresetResult(testPresetResult(sessionId))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 10))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 20))
val nums = dao.getUniqueNodeNums(sessionId)
assertEquals(setOf(10L, 20L), nums.toSet())
}
@Test
fun getMaxDistance_returnsLargestDistance() = runTest {
val sessionId = dao.insertSession(testSession())
val presetId = dao.insertPresetResult(testPresetResult(sessionId))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 1, distanceFromUser = 500.0))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 2, distanceFromUser = 15_000.0))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 3, distanceFromUser = 3_000.0))
assertEquals(15_000.0, dao.getMaxDistance(sessionId))
}
@Test
fun getMaxDistance_returnsNullWhenNoNodes() = runTest {
val sessionId = dao.insertSession(testSession())
assertNull(dao.getMaxDistance(sessionId))
}
@Test
fun getMaxDistance_returnsNullWhenAllDistancesNull() = runTest {
val sessionId = dao.insertSession(testSession())
val presetId = dao.insertPresetResult(testPresetResult(sessionId))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 1, distanceFromUser = null))
assertNull(dao.getMaxDistance(sessionId))
}
// endregion
// region Flow queries
@Test
fun getSessionFlow_emitsUpdatesOnChange() = runTest {
val id = dao.insertSession(testSession(timestamp = 5_000_000L))
val initial = dao.getSessionFlow(id).first()
assertNotNull(initial)
assertEquals("in_progress", initial.completionStatus)
}
// endregion
// region Helpers
private fun testSession(timestamp: Long = 1_000_000L, homePreset: String = "LONG_FAST") = DiscoverySessionEntity(
timestamp = timestamp,
presetsScanned = "LONG_FAST,SHORT_FAST",
homePreset = homePreset,
completionStatus = "in_progress",
)
private fun testPresetResult(sessionId: Long, presetName: String = "LONG_FAST") = DiscoveryPresetResultEntity(
sessionId = sessionId,
presetName = presetName,
dwellDurationSeconds = 30,
uniqueNodes = 5,
)
private fun testNode(presetResultId: Long, nodeNum: Long, distanceFromUser: Double? = null) = DiscoveredNodeEntity(
presetResultId = presetResultId,
nodeNum = nodeNum,
snr = 5.0f,
rssi = -70,
distanceFromUser = distanceFromUser,
)
// endregion
}

View File

@@ -398,6 +398,48 @@ class DeepLinkRouterTest {
// endregion
// region discovery deep links
@Test
fun `discovery settings sub-route navigates to discovery graph`() {
val result = route("/settings/local-mesh-discovery")
assertEquals(listOf(SettingsRoute.SettingsGraph(null), DiscoveryRoute.DiscoveryGraph), result)
}
@Test
fun `discovery session deep link resolves session ID`() {
val result = route("/settings/local-mesh-discovery/session/42")
assertEquals(
listOf(
SettingsRoute.SettingsGraph(null),
DiscoveryRoute.DiscoveryGraph,
DiscoveryRoute.DiscoverySummary(42L),
),
result,
)
}
@Test
fun `discovery alias localmeshdiscovery resolves session ID`() {
val result = route("/settings/localmeshdiscovery/session/99")
assertEquals(
listOf(
SettingsRoute.SettingsGraph(null),
DiscoveryRoute.DiscoveryGraph,
DiscoveryRoute.DiscoverySummary(99L),
),
result,
)
}
@Test
fun `discovery session with invalid ID falls back to graph`() {
val result = route("/settings/local-mesh-discovery/session/notanumber")
assertEquals(listOf(SettingsRoute.SettingsGraph(null), DiscoveryRoute.DiscoveryGraph), result)
}
// endregion
// region case insensitivity
@Test

View File

@@ -0,0 +1,254 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.discovery
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.database.dao.DiscoveryDao
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Tests for session history: sorting, session load by ID, and delete behavior (D042). */
class DiscoveryHistoryBehaviorTest {
private val dao = HistoryTestDao()
// region History sorting
@Test
fun getAllSessions_returnsNewestFirst() = runTest {
dao.insertSession(session(timestamp = 1_000L))
dao.insertSession(session(timestamp = 3_000L))
dao.insertSession(session(timestamp = 2_000L))
val sessions = dao.getAllSessions().first()
assertEquals(3, sessions.size)
assertEquals(3_000L, sessions[0].timestamp, "Newest session should be first")
assertEquals(2_000L, sessions[1].timestamp)
assertEquals(1_000L, sessions[2].timestamp, "Oldest session should be last")
}
@Test
fun getAllSessions_emptyListWhenNoSessions() = runTest {
val sessions = dao.getAllSessions().first()
assertTrue(sessions.isEmpty())
}
@Test
fun getAllSessions_singleSession() = runTest {
dao.insertSession(session(timestamp = 5_000L))
val sessions = dao.getAllSessions().first()
assertEquals(1, sessions.size)
assertEquals(5_000L, sessions.first().timestamp)
}
// endregion
// region Session load by ID
@Test
fun sessionLoadById_returnsStoredSession() = runTest {
val id = dao.insertSession(session(timestamp = 10_000L, homePreset = "MEDIUM_FAST"))
val loaded = dao.getSession(id)
assertNotNull(loaded)
assertEquals("MEDIUM_FAST", loaded.homePreset)
assertEquals(10_000L, loaded.timestamp)
}
@Test
fun sessionLoadById_returnsNullForMissing() = runTest {
assertNull(dao.getSession(999L), "Should return null for non-existent session")
}
// endregion
// region Delete behavior
@Test
fun deleteSession_removesFromHistory() = runTest {
val id1 = dao.insertSession(session(timestamp = 1L))
val id2 = dao.insertSession(session(timestamp = 2L))
dao.deleteSession(id1)
val remaining = dao.getAllSessions().first()
assertEquals(1, remaining.size)
assertEquals(id2, remaining[0].id)
}
@Test
fun deleteSession_cascadesPresetResultsAndNodes() = runTest {
val sessionId = dao.insertSession(session())
val presetId =
dao.insertPresetResult(DiscoveryPresetResultEntity(sessionId = sessionId, presetName = "LONG_FAST"))
dao.insertDiscoveredNode(DiscoveredNodeEntity(presetResultId = presetId, nodeNum = 100))
dao.deleteSession(sessionId)
assertNull(dao.getSession(sessionId))
assertTrue(dao.getPresetResults(sessionId).isEmpty(), "Preset results should cascade-delete")
assertTrue(dao.getDiscoveredNodes(presetId).isEmpty(), "Discovered nodes should cascade-delete")
}
@Test
fun deleteSession_doesNotAffectOtherSessions() = runTest {
val id1 = dao.insertSession(session(timestamp = 1L))
val id2 = dao.insertSession(session(timestamp = 2L))
val preset2 = dao.insertPresetResult(DiscoveryPresetResultEntity(sessionId = id2, presetName = "SHORT_FAST"))
dao.insertDiscoveredNode(DiscoveredNodeEntity(presetResultId = preset2, nodeNum = 42))
dao.deleteSession(id1)
assertNotNull(dao.getSession(id2), "Other sessions should be unaffected")
assertEquals(1, dao.getPresetResults(id2).size)
assertEquals(1, dao.getDiscoveredNodes(preset2).size)
}
@Test
fun deleteAllSessions_leavesEmptyHistory() = runTest {
val id1 = dao.insertSession(session(timestamp = 1L))
val id2 = dao.insertSession(session(timestamp = 2L))
dao.deleteSession(id1)
dao.deleteSession(id2)
assertTrue(dao.getAllSessions().first().isEmpty())
}
// endregion
// region Helpers
private fun session(timestamp: Long = 1_000_000L, homePreset: String = "LONG_FAST") = DiscoverySessionEntity(
timestamp = timestamp,
presetsScanned = "LONG_FAST",
homePreset = homePreset,
completionStatus = "complete",
)
// endregion
}
// region In-memory DAO for history tests
private class HistoryTestDao : DiscoveryDao {
private var nextSessionId = 1L
private var nextPresetResultId = 1L
private var nextNodeId = 1L
private val sessions = mutableMapOf<Long, DiscoverySessionEntity>()
private val presetResults = mutableMapOf<Long, DiscoveryPresetResultEntity>()
private val discoveredNodes = mutableMapOf<Long, DiscoveredNodeEntity>()
private val sessionsFlow = MutableStateFlow<List<DiscoverySessionEntity>>(emptyList())
private fun refreshSessionsFlow() {
sessionsFlow.update { sessions.values.sortedByDescending { it.timestamp } }
}
override suspend fun insertSession(session: DiscoverySessionEntity): Long {
val id = nextSessionId++
sessions[id] = session.copy(id = id)
refreshSessionsFlow()
return id
}
override suspend fun updateSession(session: DiscoverySessionEntity) {
sessions[session.id] = session
refreshSessionsFlow()
}
override fun getAllSessions(): Flow<List<DiscoverySessionEntity>> = sessionsFlow
override suspend fun getSession(sessionId: Long) = sessions[sessionId]
override fun getSessionFlow(sessionId: Long): Flow<DiscoverySessionEntity?> = MutableStateFlow(sessions[sessionId])
override suspend fun deleteSession(sessionId: Long) {
sessions.remove(sessionId)
val resultIds = presetResults.values.filter { it.sessionId == sessionId }.map { it.id }
resultIds.forEach { rid ->
discoveredNodes.entries.removeAll { it.value.presetResultId == rid }
presetResults.remove(rid)
}
refreshSessionsFlow()
}
override suspend fun insertPresetResult(result: DiscoveryPresetResultEntity): Long {
val id = nextPresetResultId++
presetResults[id] = result.copy(id = id)
return id
}
override suspend fun updatePresetResult(result: DiscoveryPresetResultEntity) {
presetResults[result.id] = result
}
override suspend fun getPresetResults(sessionId: Long) = presetResults.values.filter { it.sessionId == sessionId }
override fun getPresetResultsFlow(sessionId: Long) =
flowOf(presetResults.values.filter { it.sessionId == sessionId })
override suspend fun insertDiscoveredNode(node: DiscoveredNodeEntity): Long {
val id = nextNodeId++
discoveredNodes[id] = node.copy(id = id)
return id
}
override suspend fun insertDiscoveredNodes(nodes: List<DiscoveredNodeEntity>) {
nodes.forEach { insertDiscoveredNode(it) }
}
override suspend fun updateDiscoveredNode(node: DiscoveredNodeEntity) {
discoveredNodes[node.id] = node
}
override suspend fun getDiscoveredNodes(presetResultId: Long) =
discoveredNodes.values.filter { it.presetResultId == presetResultId }
override fun getDiscoveredNodesFlow(presetResultId: Long) =
flowOf(discoveredNodes.values.filter { it.presetResultId == presetResultId })
override suspend fun getUniqueNodeNums(sessionId: Long) = presetResults.values
.filter { it.sessionId == sessionId }
.flatMap { pr -> discoveredNodes.values.filter { it.presetResultId == pr.id } }
.map { it.nodeNum }
.distinct()
override suspend fun getUniqueNodeCount(sessionId: Long) = getUniqueNodeNums(sessionId).size
override suspend fun getMaxDistance(sessionId: Long) = presetResults.values
.filter { it.sessionId == sessionId }
.flatMap { pr -> discoveredNodes.values.filter { it.presetResultId == pr.id } }
.mapNotNull { it.distanceFromUser }
.maxOrNull()
override suspend fun getSessionWithResults(sessionId: Long) = sessions[sessionId]
}
// endregion

View File

@@ -0,0 +1,419 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.discovery
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.di.ApplicationCoroutineScope
import org.meshtastic.core.database.dao.DiscoveryDao
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ChannelOption
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.repository.DiscoveryPacketCollector
import org.meshtastic.core.repository.DiscoveryPacketCollectorRegistry
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioConfigRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.FakeServiceRepository
import org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Neighbor
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Tests for edge cases in packet collection: duplicate packets, nodes without positions, and neighbor-info-only
* sightings (D023).
*/
class DiscoveryPacketCollectionTest {
private val radioController = FakeRadioController()
private val serviceRepository = FakeServiceRepository().apply { setConnectionState(ConnectionState.Connected) }
private val nodeRepository = FakeNodeRepository()
private val radioConfigRepository =
FakeRadioConfigRepository().apply {
setLocalConfigDirect(
LocalConfig(
lora = Config.LoRaConfig(use_preset = true, modem_preset = ChannelOption.LONG_FAST.modemPreset),
),
)
}
private val collectorRegistry = PacketTestCollectorRegistry()
private val discoveryDao = InMemoryDiscoveryDao()
private val aiProvider = PacketTestAiProvider()
private fun createEngine(testScope: TestScope): DiscoveryScanEngine {
val testDispatcher = UnconfinedTestDispatcher(testScope.testScheduler)
val dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher)
val appScope =
object : ApplicationCoroutineScope {
override val coroutineContext = testDispatcher + SupervisorJob()
}
return DiscoveryScanEngine(
radioController = radioController,
serviceRepository = serviceRepository,
nodeRepository = nodeRepository,
radioConfigRepository = radioConfigRepository,
collectorRegistry = collectorRegistry,
discoveryDao = discoveryDao,
aiProvider = aiProvider,
applicationScope = appScope,
dispatchers = dispatchers,
)
}
private val testPresets = listOf(ChannelOption.LONG_FAST)
private suspend fun awaitDwell(engine: DiscoveryScanEngine) {
while (engine.scanState.value !is DiscoveryScanState.Dwell) {
delay(50)
}
}
// region Duplicate packets
@Test
fun duplicatePacketsFromSameNodeDeduplicateByNodeNum() = runTest {
val engine = createEngine(this)
nodeRepository.setMyNodeInfo(createMyNodeInfo())
engine.startScan(testPresets, dwellDurationSeconds = 60)
awaitDwell(engine)
// Send two position packets from the same node
val meshPacket1 = positionPacket(from = 1111, latI = 377749000, lonI = -1224194000, snr = 5.0f, rssi = -70)
val meshPacket2 = positionPacket(from = 1111, latI = 377750000, lonI = -1224195000, snr = 8.0f, rssi = -55)
engine.onPacketReceived(meshPacket1, dataPacket(from = 1111))
engine.onPacketReceived(meshPacket2, dataPacket(from = 1111))
engine.stopScan()
// Only one discovered node for nodeNum=1111
val nodes = discoveryDao.discoveredNodes.values.toList()
assertEquals(1, nodes.size, "Duplicate packets should map to a single node entry")
assertEquals(1111L, nodes[0].nodeNum)
// Second packet's SNR/RSSI should overwrite first
assertEquals(8.0f, nodes[0].snr, "Later SNR should overwrite")
assertEquals(-55, nodes[0].rssi, "Later RSSI should overwrite")
}
@Test
fun duplicatePacketsCountMessagesAccumulatively() = runTest {
val engine = createEngine(this)
nodeRepository.setMyNodeInfo(createMyNodeInfo())
engine.startScan(testPresets, dwellDurationSeconds = 60)
awaitDwell(engine)
// Send 3 text messages from same node
repeat(3) { engine.onPacketReceived(textMessagePacket(from = 2222), dataPacket(from = 2222)) }
engine.stopScan()
val nodes = discoveryDao.discoveredNodes.values.toList()
assertEquals(1, nodes.size)
assertEquals(3, nodes[0].messageCount, "Message count should accumulate across duplicate packets")
}
// endregion
// region Nodes without positions
@Test
fun nodeWithoutPositionHasNullLatLon() = runTest {
val engine = createEngine(this)
nodeRepository.setMyNodeInfo(createMyNodeInfo())
engine.startScan(testPresets, dwellDurationSeconds = 60)
awaitDwell(engine)
// Send a text message with no position data
engine.onPacketReceived(textMessagePacket(from = 3333), dataPacket(from = 3333))
engine.stopScan()
val nodes = discoveryDao.discoveredNodes.values.toList()
assertEquals(1, nodes.size)
assertNull(nodes[0].latitude, "Node without position should have null latitude")
assertNull(nodes[0].longitude, "Node without position should have null longitude")
assertNull(nodes[0].distanceFromUser, "Node without position should have null distance")
}
@Test
fun nodeWithZeroPositionTreatedAsNoPosition() = runTest {
val engine = createEngine(this)
nodeRepository.setMyNodeInfo(createMyNodeInfo())
engine.startScan(testPresets, dwellDurationSeconds = 60)
awaitDwell(engine)
// Position of 0,0 is treated as invalid/no fix
val packet = positionPacket(from = 4444, latI = 0, lonI = 0)
engine.onPacketReceived(packet, dataPacket(from = 4444))
engine.stopScan()
val nodes = discoveryDao.discoveredNodes.values.toList()
assertEquals(1, nodes.size)
assertNull(nodes[0].distanceFromUser, "Zero-position node should have null distance")
}
// endregion
// region Neighbor-info-only sightings
@Test
fun neighborInfoOnlyNodeIsMarkedAsMesh() = runTest {
val engine = createEngine(this)
nodeRepository.setMyNodeInfo(createMyNodeInfo())
engine.startScan(testPresets, dwellDurationSeconds = 60)
awaitDwell(engine)
// Send a neighbor info packet that references node 5555 as a mesh neighbor
val niPacket = neighborInfoPacket(from = 9999, neighborNodeIds = listOf(5555))
engine.onPacketReceived(niPacket, dataPacket(from = 9999))
engine.stopScan()
// Node 5555 should appear as a mesh neighbor even though we never received a direct packet from it
val nodes = discoveryDao.discoveredNodes.values.toList()
val meshNode = nodes.find { it.nodeNum == 5555L }
assertTrue(meshNode != null, "Neighbor-info-only node should be persisted")
assertEquals("mesh", meshNode.neighborType, "Neighbor-info-only node should have 'mesh' type")
}
@Test
fun neighborInfoDoesNotOverrideDirectType() = runTest {
val engine = createEngine(this)
nodeRepository.setMyNodeInfo(createMyNodeInfo())
engine.startScan(testPresets, dwellDurationSeconds = 60)
awaitDwell(engine)
// First: receive a direct packet from node 6666
engine.onPacketReceived(
positionPacket(from = 6666, latI = 377749000, lonI = -1224194000, snr = 10f, rssi = -40),
dataPacket(from = 6666),
)
// Then: receive neighbor info that also references 6666
val niPacket = neighborInfoPacket(from = 8888, neighborNodeIds = listOf(6666))
engine.onPacketReceived(niPacket, dataPacket(from = 8888))
engine.stopScan()
val nodes = discoveryDao.discoveredNodes.values.toList()
val directNode = nodes.find { it.nodeNum == 6666L }
assertTrue(directNode != null, "Node should be persisted")
assertEquals("direct", directNode.neighborType, "Direct type should not be overridden by neighbor-info")
assertEquals(10f, directNode.snr, "SNR from direct packet should be preserved")
}
@Test
fun neighborInfoMultipleNeighborsAllRecorded() = runTest {
val engine = createEngine(this)
nodeRepository.setMyNodeInfo(createMyNodeInfo())
engine.startScan(testPresets, dwellDurationSeconds = 60)
awaitDwell(engine)
val niPacket = neighborInfoPacket(from = 7777, neighborNodeIds = listOf(101, 102, 103))
engine.onPacketReceived(niPacket, dataPacket(from = 7777))
engine.stopScan()
val nodes = discoveryDao.discoveredNodes.values.toList()
// Node 7777 (the sender) + 3 mesh neighbors
val meshNodes = nodes.filter { it.neighborType == "mesh" }
assertEquals(3, meshNodes.size, "All neighbor IDs from NeighborInfo should be recorded")
assertTrue(meshNodes.map { it.nodeNum }.containsAll(listOf(101L, 102L, 103L)))
}
// endregion
// region Helpers
private fun createMyNodeInfo(nodeNum: Int = 1000) = MyNodeInfo(
myNodeNum = nodeNum,
hasGPS = true,
model = "TestModel",
firmwareVersion = "2.0.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 1L,
messageTimeoutMsec = 5000,
minAppVersion = 1,
maxChannels = 8,
hasWifi = false,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = "test-device",
)
private fun positionPacket(from: Int, latI: Int, lonI: Int, snr: Float = 5.5f, rssi: Int = -70): MeshPacket {
val posPayload = Position.ADAPTER.encode(Position(latitude_i = latI, longitude_i = lonI)).toByteString()
val data = Data(portnum = PortNum.POSITION_APP, payload = posPayload)
return MeshPacket(from = from, decoded = data, rx_snr = snr, rx_rssi = rssi)
}
private fun textMessagePacket(from: Int): MeshPacket {
val data = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString())
return MeshPacket(from = from, decoded = data, rx_snr = 3.0f, rx_rssi = -80)
}
private fun neighborInfoPacket(from: Int, neighborNodeIds: List<Int>): MeshPacket {
val neighbors = neighborNodeIds.map { Neighbor(node_id = it) }
val ni = NeighborInfo(node_id = from, neighbors = neighbors)
val payload = NeighborInfo.ADAPTER.encode(ni).toByteString()
val data = Data(portnum = PortNum.NEIGHBORINFO_APP, payload = payload)
return MeshPacket(from = from, decoded = data)
}
private fun dataPacket(from: Int) = DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = ByteString.EMPTY,
dataType = PortNum.POSITION_APP.value,
from = "!${from.toString(16)}",
hopStart = 3,
hopLimit = 3,
)
// endregion
}
// region Inline test doubles
private class PacketTestCollectorRegistry : DiscoveryPacketCollectorRegistry {
override var collector: DiscoveryPacketCollector? = null
}
private class PacketTestAiProvider : DiscoverySummaryAiProvider {
override val isAvailable: Boolean = false
override suspend fun generateSessionSummary(
session: DiscoverySessionEntity,
presetResults: List<DiscoveryPresetResultEntity>,
): String? = null
override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String? = null
}
private class InMemoryDiscoveryDao : DiscoveryDao {
private var nextSessionId = 1L
private var nextPresetResultId = 1L
private var nextNodeId = 1L
val sessions = mutableMapOf<Long, DiscoverySessionEntity>()
val presetResults = mutableMapOf<Long, DiscoveryPresetResultEntity>()
val discoveredNodes = mutableMapOf<Long, DiscoveredNodeEntity>()
override suspend fun insertSession(session: DiscoverySessionEntity): Long {
val id = nextSessionId++
sessions[id] = session.copy(id = id)
return id
}
override suspend fun updateSession(session: DiscoverySessionEntity) {
sessions[session.id] = session
}
override fun getAllSessions(): Flow<List<DiscoverySessionEntity>> =
flowOf(sessions.values.sortedByDescending { it.timestamp })
override suspend fun getSession(sessionId: Long): DiscoverySessionEntity? = sessions[sessionId]
override fun getSessionFlow(sessionId: Long): Flow<DiscoverySessionEntity?> = MutableStateFlow(sessions[sessionId])
override suspend fun deleteSession(sessionId: Long) {
sessions.remove(sessionId)
val resultIds = presetResults.values.filter { it.sessionId == sessionId }.map { it.id }
resultIds.forEach { rid ->
discoveredNodes.entries.removeAll { it.value.presetResultId == rid }
presetResults.remove(rid)
}
}
override suspend fun insertPresetResult(result: DiscoveryPresetResultEntity): Long {
val id = nextPresetResultId++
presetResults[id] = result.copy(id = id)
return id
}
override suspend fun updatePresetResult(result: DiscoveryPresetResultEntity) {
presetResults[result.id] = result
}
override suspend fun getPresetResults(sessionId: Long) = presetResults.values.filter { it.sessionId == sessionId }
override fun getPresetResultsFlow(sessionId: Long) =
flowOf(presetResults.values.filter { it.sessionId == sessionId })
override suspend fun insertDiscoveredNode(node: DiscoveredNodeEntity): Long {
val id = nextNodeId++
discoveredNodes[id] = node.copy(id = id)
return id
}
override suspend fun insertDiscoveredNodes(nodes: List<DiscoveredNodeEntity>) {
nodes.forEach { insertDiscoveredNode(it) }
}
override suspend fun updateDiscoveredNode(node: DiscoveredNodeEntity) {
discoveredNodes[node.id] = node
}
override suspend fun getDiscoveredNodes(presetResultId: Long) =
discoveredNodes.values.filter { it.presetResultId == presetResultId }
override fun getDiscoveredNodesFlow(presetResultId: Long) =
flowOf(discoveredNodes.values.filter { it.presetResultId == presetResultId })
override suspend fun getUniqueNodeNums(sessionId: Long) = presetResults.values
.filter { it.sessionId == sessionId }
.flatMap { pr -> discoveredNodes.values.filter { it.presetResultId == pr.id } }
.map { it.nodeNum }
.distinct()
override suspend fun getUniqueNodeCount(sessionId: Long) = getUniqueNodeNums(sessionId).size
override suspend fun getMaxDistance(sessionId: Long) = presetResults.values
.filter { it.sessionId == sessionId }
.flatMap { pr -> discoveredNodes.values.filter { it.presetResultId == pr.id } }
.mapNotNull { it.distanceFromUser }
.maxOrNull()
override suspend fun getSessionWithResults(sessionId: Long) = sessions[sessionId]
}
// endregion

View File

@@ -30,7 +30,7 @@
- [X] **D007** [P] Add `DiscoverySessionEntity`, `DiscoveryPresetResultEntity`, and `DiscoveredNodeEntity` under `core:database`.
- [X] **D008** [P] Add discovery DAO interfaces and relation models.
- [X] **D009** Register entities / DAOs in `MeshtasticDatabase` and bump the schema version.
- [ ] **D010** Add DAO tests for insert, relation loading, sort order, and cascade deletion.
- [X] **D010** Add DAO tests for insert, relation loading, sort order, and cascade deletion.
- [ ] **D011** Add migration coverage for the new schema version.
**Depends on**: D001
@@ -56,7 +56,7 @@
- [ ] **D020** [P] Trigger neighbor info requests at dwell boundaries through the existing command path.
- [X] **D021** [P] Aggregate per-preset metrics (packet count, telemetry count, neighbor count, unique nodes, best distance, link quality).
- [X] **D022** [P] Upsert `DiscoveredNodeEntity` rows with deduped per-preset observations.
- [ ] **D023** Add tests for duplicate packets, nodes without positions, and neighbor-info-only sightings.
- [X] **D023** Add tests for duplicate packets, nodes without positions, and neighbor-info-only sightings.
**Depends on**: D014-D017
**Exit criteria**: preset results and per-node observations are populated from live/shared data sources.
@@ -99,7 +99,7 @@
- [X] **D039** [P] Implement session detail routing and history-to-detail navigation.
- [X] **D040** [P] Implement delete flow with cascade validation.
- [X] **D041** Ensure historical sessions load entirely from Room without requiring a live radio connection.
- [ ] **D042** Add tests for history sorting, deep-link session load, and delete behavior.
- [X] **D042** Add tests for history sorting, deep-link session load, and delete behavior.
**Depends on**: D007-D010, D029-D031
**Exit criteria**: stored sessions can be reopened and managed after app restart.