diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonDiscoveryDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonDiscoveryDaoTest.kt
new file mode 100644
index 000000000..40e2b9e8b
--- /dev/null
+++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonDiscoveryDaoTest.kt
@@ -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 .
+ */
+@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
+}
diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt
index 04bda7472..05b8a4cae 100644
--- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt
+++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt
@@ -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
diff --git a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryHistoryBehaviorTest.kt b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryHistoryBehaviorTest.kt
new file mode 100644
index 000000000..8f38d0f0c
--- /dev/null
+++ b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryHistoryBehaviorTest.kt
@@ -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 .
+ */
+@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()
+ private val presetResults = mutableMapOf()
+ private val discoveredNodes = mutableMapOf()
+ private val sessionsFlow = MutableStateFlow>(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> = sessionsFlow
+
+ override suspend fun getSession(sessionId: Long) = sessions[sessionId]
+
+ override fun getSessionFlow(sessionId: Long): Flow = 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) {
+ 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
diff --git a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryPacketCollectionTest.kt b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryPacketCollectionTest.kt
new file mode 100644
index 000000000..9d8006df8
--- /dev/null
+++ b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryPacketCollectionTest.kt
@@ -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 .
+ */
+@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): 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,
+ ): 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()
+ val presetResults = mutableMapOf()
+ val discoveredNodes = mutableMapOf()
+
+ 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> =
+ flowOf(sessions.values.sortedByDescending { it.timestamp })
+
+ override suspend fun getSession(sessionId: Long): DiscoverySessionEntity? = sessions[sessionId]
+
+ override fun getSessionFlow(sessionId: Long): Flow = 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) {
+ 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
diff --git a/specs/001-local-mesh-discovery/tasks.md b/specs/001-local-mesh-discovery/tasks.md
index 2cd07b5fe..7f0edcc7f 100644
--- a/specs/001-local-mesh-discovery/tasks.md
+++ b/specs/001-local-mesh-discovery/tasks.md
@@ -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.