mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 08:42:01 -04:00
test(discovery): add DAO, packet collection, history, and deep-link tests (D010, D023, D042)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user