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.