test(node): Refactor MetricsViewModelTest and extract interfaces for mocking

This commit is contained in:
James Rich 2026-03-18 20:03:52 -05:00
parent 30a32b5dea
commit 79e0592862
12 changed files with 639 additions and 421 deletions

View file

@ -3,6 +3,6 @@
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
---
- [ ] **Track: Expand Testing Coverage**
- [~] **Track: Expand Testing Coverage**
*Link: [./tracks/expand_testing_20260318/](./tracks/expand_testing_20260318/)*

View file

@ -5,7 +5,7 @@
- [x] Task: Conductor - User Manual Verification 'Phase 1: Baseline Measurement' (Protocol in workflow.md) 6d9ad468c
## Phase 2: Feature ViewModel Migration to Turbine
- [ ] Task: Refactor `MetricsViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`.
- [~] Task: Refactor `MetricsViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`.
- [ ] Task: Refactor `MessageViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`.
- [ ] Task: Refactor `RadioConfigViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`.
- [ ] Task: Refactor `NodeListViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`.

View file

@ -24,7 +24,7 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.repository.TracerouteSnapshotRepository
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse

View file

@ -27,22 +27,23 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.TracerouteSnapshotRepository
import org.meshtastic.proto.Position
@Single
class TracerouteSnapshotRepository(
class TracerouteSnapshotRepositoryImpl(
private val dbManager: DatabaseProvider,
private val dispatchers: CoroutineDispatchers,
) {
) : TracerouteSnapshotRepository {
fun getSnapshotPositions(logUuid: String): Flow<Map<Int, Position>> = dbManager.currentDb
override fun getSnapshotPositions(logUuid: String): Flow<Map<Int, Position>> = dbManager.currentDb
.flatMapLatest { it.tracerouteNodePositionDao().getByLogUuid(logUuid) }
.distinctUntilChanged()
.mapLatest { list -> list.associate { it.nodeNum to it.position } }
.flowOn(dispatchers.io)
.conflate()
suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map<Int, Position>) =
override suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map<Int, Position>) =
withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.tracerouteNodePositionDao()
dao.deleteByLogUuid(logUuid)

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import kotlinx.coroutines.flow.Flow
import org.meshtastic.proto.Position
/**
* Repository interface for managing snapshots of traceroute results.
*/
interface TracerouteSnapshotRepository {
/**
* Returns a reactive flow of positions associated with a specific traceroute log.
*/
fun getSnapshotPositions(logUuid: String): Flow<Map<Int, Position>>
/**
* Persists a set of positions for a traceroute log.
*/
suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map<Int, Position>)
}

View file

@ -52,9 +52,9 @@ open class AlertManager {
)
private val _currentAlert = MutableStateFlow<AlertData?>(null)
val currentAlert = _currentAlert.asStateFlow()
open val currentAlert = _currentAlert.asStateFlow()
fun showAlert(
open fun showAlert(
title: String? = null,
titleRes: StringResource? = null,
message: String? = null,
@ -97,7 +97,7 @@ open class AlertManager {
)
}
fun dismissAlert() {
open fun dismissAlert() {
_currentAlert.value = null
}
}

View file

@ -0,0 +1,141 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.request_air_quality_metrics
import org.meshtastic.core.resources.request_device_metrics
import org.meshtastic.core.resources.request_environment_metrics
import org.meshtastic.core.resources.request_host_metrics
import org.meshtastic.core.resources.request_pax_metrics
import org.meshtastic.core.resources.request_power_metrics
import org.meshtastic.core.resources.requesting_from
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.user_info
@Single
class CommonNodeRequestActions constructor(private val radioController: RadioController) : NodeRequestActions {
private val _effects = MutableSharedFlow<NodeRequestEffect>()
override val effects: SharedFlow<NodeRequestEffect> = _effects.asSharedFlow()
private val _lastTracerouteTime = MutableStateFlow<Long?>(null)
override val lastTracerouteTime: StateFlow<Long?> = _lastTracerouteTime.asStateFlow()
private val _lastRequestNeighborTimes = MutableStateFlow<Map<Int, Long>>(emptyMap())
override val lastRequestNeighborTimes: StateFlow<Map<Int, Long>> = _lastRequestNeighborTimes.asStateFlow()
override fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting UserInfo for '$destNum'" }
radioController.requestUserInfo(destNum)
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName),
),
)
}
}
override fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting NeighborInfo for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestNeighborInfo(packetId, destNum)
_lastRequestNeighborTimes.update { it + (destNum to nowMillis) }
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName),
),
)
}
}
override fun requestPosition(
scope: CoroutineScope,
destNum: Int,
longName: String,
position: Position,
) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting position for '$destNum'" }
radioController.requestPosition(destNum, position)
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.position, longName),
),
)
}
}
override fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting telemetry for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestTelemetry(packetId, destNum, type.ordinal)
val typeRes =
when (type) {
TelemetryType.DEVICE -> Res.string.request_device_metrics
TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics
TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics
TelemetryType.POWER -> Res.string.request_power_metrics
TelemetryType.LOCAL_STATS -> Res.string.signal_quality
TelemetryType.HOST -> Res.string.request_host_metrics
TelemetryType.PAX -> Res.string.request_pax_metrics
}
_effects.emit(
NodeRequestEffect.ShowFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)),
)
}
}
override fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting traceroute for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestTraceroute(packetId, destNum)
_lastTracerouteTime.value = nowMillis
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName),
),
)
}
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* 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
@ -16,130 +16,33 @@
*/
package org.meshtastic.feature.node.detail
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.request_air_quality_metrics
import org.meshtastic.core.resources.request_device_metrics
import org.meshtastic.core.resources.request_environment_metrics
import org.meshtastic.core.resources.request_host_metrics
import org.meshtastic.core.resources.request_pax_metrics
import org.meshtastic.core.resources.request_power_metrics
import org.meshtastic.core.resources.requesting_from
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.user_info
sealed class NodeRequestEffect {
data class ShowFeedback(val text: UiText) : NodeRequestEffect()
}
@Single
class NodeRequestActions constructor(private val radioController: RadioController) {
private val _effects = MutableSharedFlow<NodeRequestEffect>()
val effects: SharedFlow<NodeRequestEffect> = _effects.asSharedFlow()
private val _lastTracerouteTime = MutableStateFlow<Long?>(null)
val lastTracerouteTime: StateFlow<Long?> = _lastTracerouteTime.asStateFlow()
private val _lastRequestNeighborTimes = MutableStateFlow<Map<Int, Long>>(emptyMap())
val lastRequestNeighborTimes: StateFlow<Map<Int, Long>> = _lastRequestNeighborTimes.asStateFlow()
fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting UserInfo for '$destNum'" }
radioController.requestUserInfo(destNum)
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName),
),
)
}
}
fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting NeighborInfo for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestNeighborInfo(packetId, destNum)
_lastRequestNeighborTimes.update { it + (destNum to nowMillis) }
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName),
),
)
}
}
/**
* Interface for high-level node request actions (e.g., requesting user info, position, telemetry).
*/
interface NodeRequestActions {
val effects: SharedFlow<NodeRequestEffect>
val lastTracerouteTime: StateFlow<Long?>
val lastRequestNeighborTimes: StateFlow<Map<Int, Long>>
fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String)
fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String)
fun requestPosition(
scope: CoroutineScope,
destNum: Int,
longName: String,
position: Position = Position(0.0, 0.0, 0),
) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting position for '$destNum'" }
radioController.requestPosition(destNum, position)
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.position, longName),
),
)
}
}
fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting telemetry for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestTelemetry(packetId, destNum, type.ordinal)
val typeRes =
when (type) {
TelemetryType.DEVICE -> Res.string.request_device_metrics
TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics
TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics
TelemetryType.POWER -> Res.string.request_power_metrics
TelemetryType.LOCAL_STATS -> Res.string.signal_quality
TelemetryType.HOST -> Res.string.request_host_metrics
TelemetryType.PAX -> Res.string.request_pax_metrics
}
_effects.emit(
NodeRequestEffect.ShowFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)),
)
}
}
fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting traceroute for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestTraceroute(packetId, destNum)
_lastTracerouteTime.value = nowMillis
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName),
),
)
}
}
)
fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType)
fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String)
}

View file

@ -0,0 +1,237 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.domain.usecase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Single
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.fallback_node_name
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.feature.node.detail.NodeDetailUiState
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
@Single
class CommonGetNodeDetailsUseCase
constructor(
private val nodeRepository: NodeRepository,
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
) : GetNodeDetailsUseCase {
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
override operator fun invoke(nodeId: Int): Flow<NodeDetailUiState> =
nodeRepository.effectiveLogNodeId(nodeId).flatMapLatest { effectiveNodeId ->
buildFlow(nodeId, effectiveNodeId)
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun buildFlow(nodeId: Int, effectiveNodeId: Int): Flow<NodeDetailUiState> {
val nodeFlow =
nodeRepository.nodeDBbyNum.map { it[nodeId] ?: Node.createFallback(nodeId, "") }.distinctUntilChanged()
// 1. Logs & Metrics Data
val metricsLogsFlow =
combine(
meshLogRepository.getTelemetryFrom(effectiveNodeId).onStart { emit(emptyList()) },
meshLogRepository.getMeshPacketsFrom(effectiveNodeId).onStart { emit(emptyList()) },
meshLogRepository.getMeshPacketsFrom(effectiveNodeId, PortNum.POSITION_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.PAXCOUNTER_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.TRACEROUTE_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.NEIGHBORINFO_APP.value).onStart {
emit(emptyList())
},
) { args: Array<List<Any?>> ->
@Suppress("UNCHECKED_CAST")
LogsGroup(
telemetry = args[0] as List<Telemetry>,
packets = args[1] as List<MeshPacket>,
posPackets = args[2] as List<MeshPacket>,
pax = args[3] as List<MeshLog>,
trRes = args[4] as List<MeshLog>,
niRes = args[5] as List<MeshLog>,
)
}
// 2. Identity & Config
val identityFlow =
combine(
nodeRepository.ourNodeInfo,
nodeRepository.myNodeInfo,
radioConfigRepository.deviceProfileFlow.onStart { emit(DeviceProfile()) },
) { ourNode, myInfo, profile ->
IdentityGroup(ourNode, myInfo, profile)
}
// 3. Metadata & Request Timestamps
val metadataFlow =
combine(
meshLogRepository
.getMyNodeInfo()
.map { it?.firmware_edition }
.distinctUntilChanged()
.onStart { emit(null) },
firmwareReleaseRepository.stableRelease,
firmwareReleaseRepository.alphaRelease,
nodeRequestActions.lastTracerouteTime,
nodeRequestActions.lastRequestNeighborTimes.map { it[nodeId] },
) { edition, stable, alpha, trTime, niTime ->
MetadataGroup(edition = edition, stable = stable, alpha = alpha, trTime = trTime, niTime = niTime)
}
// 4. Requests History (we still query request logs by the target nodeId)
val requestsFlow =
combine(
meshLogRepository.getRequestLogs(nodeId, PortNum.TRACEROUTE_APP).onStart { emit(emptyList()) },
meshLogRepository.getRequestLogs(nodeId, PortNum.NEIGHBORINFO_APP).onStart { emit(emptyList()) },
) { trReqs, niReqs ->
trReqs to niReqs
}
// Assemble final UI state
return combine(nodeFlow, metricsLogsFlow, identityFlow, metadataFlow, requestsFlow) {
node,
logs,
identity,
metadata,
requests,
->
val (trReqs, niReqs) = requests
val isLocal = node.num == identity.ourNode?.num
val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null
val hw = deviceHardwareRepository.getDeviceHardwareByModel(node.user.hw_model.value, pioEnv).getOrNull()
val moduleConfig = identity.profile.module_config
val displayUnits = identity.profile.config?.display?.units ?: Config.DisplayConfig.DisplayUnits.METRIC
val metricsState =
MetricsState(
node = node,
isLocal = isLocal,
deviceHardware = hw,
reportedTarget = pioEnv,
isManaged = identity.profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits,
deviceMetrics = logs.telemetry.filter { it.device_metrics != null },
powerMetrics = logs.telemetry.filter { it.power_metrics != null },
hostMetrics = logs.telemetry.filter { it.host_metrics != null },
signalMetrics = logs.packets.filter { it.isDirectSignal() },
positionLogs = logs.posPackets.mapNotNull { it.toPosition() },
paxMetrics = logs.pax,
tracerouteRequests = trReqs,
tracerouteResults = logs.trRes,
neighborInfoRequests = niReqs,
neighborInfoResults = logs.niRes,
firmwareEdition = metadata.edition,
latestStableFirmware = metadata.stable ?: FirmwareRelease(),
latestAlphaFirmware = metadata.alpha ?: FirmwareRelease(),
)
val environmentState =
EnvironmentMetricsState(environmentMetrics = logs.telemetry.filter { it.hasValidEnvironmentMetrics() })
val availableLogs = buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
@Suppress("MagicNumber")
val nodeName =
node.user.long_name.takeIf { it.isNotBlank() }?.let { UiText.DynamicString(it) }
?: UiText.Resource(Res.string.fallback_node_name, node.user.id.takeLast(4))
NodeDetailUiState(
node = node,
nodeName = nodeName,
ourNode = identity.ourNode,
metricsState = metricsState,
environmentState = environmentState,
availableLogs = availableLogs,
lastTracerouteTime = metadata.trTime,
lastRequestNeighborsTime = metadata.niTime,
)
}
}
private data class LogsGroup(
val telemetry: List<Telemetry>,
val packets: List<MeshPacket>,
val posPackets: List<MeshPacket>,
val pax: List<MeshLog>,
val trRes: List<MeshLog>,
val niRes: List<MeshLog>,
)
private data class IdentityGroup(val ourNode: Node?, val myInfo: MyNodeInfo?, val profile: DeviceProfile)
private data class MetadataGroup(
val edition: FirmwareEdition?,
val stable: FirmwareRelease?,
val alpha: FirmwareRelease?,
val trTime: Long?,
val niTime: Long?,
)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* 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
@ -16,222 +16,12 @@
*/
package org.meshtastic.feature.node.domain.usecase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Single
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.fallback_node_name
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.feature.node.detail.NodeDetailUiState
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
@Single
class GetNodeDetailsUseCase
constructor(
private val nodeRepository: NodeRepository,
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
) {
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
operator fun invoke(nodeId: Int): Flow<NodeDetailUiState> =
nodeRepository.effectiveLogNodeId(nodeId).flatMapLatest { effectiveNodeId ->
buildFlow(nodeId, effectiveNodeId)
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun buildFlow(nodeId: Int, effectiveNodeId: Int): Flow<NodeDetailUiState> {
val nodeFlow =
nodeRepository.nodeDBbyNum.map { it[nodeId] ?: Node.createFallback(nodeId, "") }.distinctUntilChanged()
// 1. Logs & Metrics Data
val metricsLogsFlow =
combine(
meshLogRepository.getTelemetryFrom(effectiveNodeId).onStart { emit(emptyList()) },
meshLogRepository.getMeshPacketsFrom(effectiveNodeId).onStart { emit(emptyList()) },
meshLogRepository.getMeshPacketsFrom(effectiveNodeId, PortNum.POSITION_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.PAXCOUNTER_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.TRACEROUTE_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.NEIGHBORINFO_APP.value).onStart {
emit(emptyList())
},
) { args: Array<List<Any?>> ->
@Suppress("UNCHECKED_CAST")
LogsGroup(
telemetry = args[0] as List<Telemetry>,
packets = args[1] as List<MeshPacket>,
posPackets = args[2] as List<MeshPacket>,
pax = args[3] as List<MeshLog>,
trRes = args[4] as List<MeshLog>,
niRes = args[5] as List<MeshLog>,
)
}
// 2. Identity & Config
val identityFlow =
combine(
nodeRepository.ourNodeInfo,
nodeRepository.myNodeInfo,
radioConfigRepository.deviceProfileFlow.onStart { emit(DeviceProfile()) },
) { ourNode, myInfo, profile ->
IdentityGroup(ourNode, myInfo, profile)
}
// 3. Metadata & Request Timestamps
val metadataFlow =
combine(
meshLogRepository
.getMyNodeInfo()
.map { it?.firmware_edition }
.distinctUntilChanged()
.onStart { emit(null) },
firmwareReleaseRepository.stableRelease,
firmwareReleaseRepository.alphaRelease,
nodeRequestActions.lastTracerouteTime,
nodeRequestActions.lastRequestNeighborTimes.map { it[nodeId] },
) { edition, stable, alpha, trTime, niTime ->
MetadataGroup(edition = edition, stable = stable, alpha = alpha, trTime = trTime, niTime = niTime)
}
// 4. Requests History (we still query request logs by the target nodeId)
val requestsFlow =
combine(
meshLogRepository.getRequestLogs(nodeId, PortNum.TRACEROUTE_APP).onStart { emit(emptyList()) },
meshLogRepository.getRequestLogs(nodeId, PortNum.NEIGHBORINFO_APP).onStart { emit(emptyList()) },
) { trReqs, niReqs ->
trReqs to niReqs
}
// Assemble final UI state
return combine(nodeFlow, metricsLogsFlow, identityFlow, metadataFlow, requestsFlow) {
node,
logs,
identity,
metadata,
requests,
->
val (trReqs, niReqs) = requests
val isLocal = node.num == identity.ourNode?.num
val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null
val hw = deviceHardwareRepository.getDeviceHardwareByModel(node.user.hw_model.value, pioEnv).getOrNull()
val moduleConfig = identity.profile.module_config
val displayUnits = identity.profile.config?.display?.units ?: Config.DisplayConfig.DisplayUnits.METRIC
val metricsState =
MetricsState(
node = node,
isLocal = isLocal,
deviceHardware = hw,
reportedTarget = pioEnv,
isManaged = identity.profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits,
deviceMetrics = logs.telemetry.filter { it.device_metrics != null },
powerMetrics = logs.telemetry.filter { it.power_metrics != null },
hostMetrics = logs.telemetry.filter { it.host_metrics != null },
signalMetrics = logs.packets.filter { it.isDirectSignal() },
positionLogs = logs.posPackets.mapNotNull { it.toPosition() },
paxMetrics = logs.pax,
tracerouteRequests = trReqs,
tracerouteResults = logs.trRes,
neighborInfoRequests = niReqs,
neighborInfoResults = logs.niRes,
firmwareEdition = metadata.edition,
latestStableFirmware = metadata.stable ?: FirmwareRelease(),
latestAlphaFirmware = metadata.alpha ?: FirmwareRelease(),
)
val environmentState =
EnvironmentMetricsState(environmentMetrics = logs.telemetry.filter { it.hasValidEnvironmentMetrics() })
val availableLogs = buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
@Suppress("MagicNumber")
val nodeName =
node.user.long_name.takeIf { it.isNotBlank() }?.let { UiText.DynamicString(it) }
?: UiText.Resource(Res.string.fallback_node_name, node.user.id.takeLast(4))
NodeDetailUiState(
node = node,
nodeName = nodeName,
ourNode = identity.ourNode,
metricsState = metricsState,
environmentState = environmentState,
availableLogs = availableLogs,
lastTracerouteTime = metadata.trTime,
lastRequestNeighborsTime = metadata.niTime,
)
}
}
private data class LogsGroup(
val telemetry: List<Telemetry>,
val packets: List<MeshPacket>,
val posPackets: List<MeshPacket>,
val pax: List<MeshLog>,
val trRes: List<MeshLog>,
val niRes: List<MeshLog>,
)
private data class IdentityGroup(val ourNode: Node?, val myInfo: MyNodeInfo?, val profile: DeviceProfile)
private data class MetadataGroup(
val edition: FirmwareEdition?,
val stable: FirmwareRelease?,
val alpha: FirmwareRelease?,
val trTime: Long?,
val niTime: Long?,
)
/**
* Use case for retrieving comprehensive details for a specific node.
*/
interface GetNodeDetailsUseCase {
operator fun invoke(nodeId: Int): Flow<NodeDetailUiState>
}

View file

@ -44,7 +44,7 @@ import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.repository.TracerouteSnapshotRepository
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node

View file

@ -16,103 +16,214 @@
*/
package org.meshtastic.feature.node.metrics
class MetricsViewModelTest {
/*
import app.cash.turbine.test
import dev.mokkery.MockMode
import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verifySuspend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okio.Buffer
import okio.BufferedSink
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.repository.TracerouteSnapshotRepository
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.node.detail.NodeDetailUiState
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.Position
import org.meshtastic.proto.Telemetry
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
private val dispatchers =
CoroutineDispatchers(
main = kotlinx.coroutines.Dispatchers.Unconfined,
io = kotlinx.coroutines.Dispatchers.Unconfined,
default = kotlinx.coroutines.Dispatchers.Unconfined,
)
class MetricsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val dispatchers = CoroutineDispatchers(
main = testDispatcher,
io = testDispatcher,
default = testDispatcher,
)
private val meshLogRepository: MeshLogRepository = mock()
private val serviceRepository: ServiceRepository = mock()
private val nodeRepository: NodeRepository = mock()
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository = mock()
private val nodeRequestActions: NodeRequestActions = mock()
private val alertManager: org.meshtastic.core.ui.util.AlertManager = mock()
private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock()
private val fileService: FileService = mock()
private lateinit var viewModel: MetricsViewModel
@Before
@BeforeTest
fun setUp() {
Dispatchers.setMain(dispatchers.main)
Dispatchers.setMain(testDispatcher)
viewModel =
MetricsViewModel(
destNum = 1234,
dispatchers = dispatchers,
meshLogRepository = meshLogRepository,
serviceRepository = serviceRepository,
nodeRepository = nodeRepository,
tracerouteSnapshotRepository = tracerouteSnapshotRepository,
nodeRequestActions = nodeRequestActions,
alertManager = alertManager,
getNodeDetailsUseCase = getNodeDetailsUseCase,
fileService = fileService,
)
// Default setup for flows
every { serviceRepository.tracerouteResponse } returns MutableStateFlow(null)
every { nodeRequestActions.effects } returns mock()
every { nodeRequestActions.lastTracerouteTime } returns MutableStateFlow(null)
every { nodeRequestActions.lastRequestNeighborTimes } returns MutableStateFlow(emptyMap())
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
// Mock the case where we get node details
every { getNodeDetailsUseCase(any()) } returns flowOf(NodeDetailUiState())
viewModel = createViewModel()
}
@After
private fun createViewModel(destNum: Int = 1234) = MetricsViewModel(
destNum = destNum,
dispatchers = dispatchers,
meshLogRepository = meshLogRepository,
serviceRepository = serviceRepository,
nodeRepository = nodeRepository,
tracerouteSnapshotRepository = tracerouteSnapshotRepository,
nodeRequestActions = nodeRequestActions,
alertManager = alertManager,
getNodeDetailsUseCase = getNodeDetailsUseCase,
fileService = fileService,
)
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test fun testInitialization() = runTest { assertNotNull(viewModel) }
@Test
fun testSavePositionCSV() = runTest {
val testPosition =
Position(
latitude_i = 123456789,
longitude_i = -987654321,
altitude = 100,
sats_in_view = 5,
ground_speed = 10,
ground_track = 123456,
time = 1700000000,
)
everySuspend { getNodeDetailsUseCase(any()) } returns
flowOf(NodeDetailUiState(metricsState = MetricsState(positionLogs = listOf(testPosition))))
// Re-init view model so it picks up the mocked flow
viewModel =
MetricsViewModel(
destNum = 1234,
dispatchers = dispatchers,
meshLogRepository = meshLogRepository,
serviceRepository = serviceRepository,
nodeRepository = nodeRepository,
tracerouteSnapshotRepository = tracerouteSnapshotRepository,
nodeRequestActions = nodeRequestActions,
alertManager = alertManager,
getNodeDetailsUseCase = getNodeDetailsUseCase,
fileService = fileService,
)
// Wait for state to populate
val collectionJob = backgroundScope.launch { viewModel.state.collect {} }
kotlinx.coroutines.yield()
advanceUntilIdle()
val uri = MeshtasticUri("content://test")
viewModel.savePositionCSV(uri)
advanceUntilIdle()
verifySuspend { fileService.write(uri, any()) }
val buffer = Buffer()
blockSlot.captured.invoke(buffer)
val csvOutput = buffer.readUtf8()
assertEquals(
"\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"\n",
csvOutput.substringBefore("\n") + "\n",
)
assert(csvOutput.contains("12.345")) { "Missing latitude in $csvOutput" }
assert(csvOutput.contains("-98.765")) { "Missing longitude in $csvOutput" }
assert(csvOutput.contains("\"100\",\"5\",\"10\",\"1.23\"\n")) { "Missing rest in $csvOutput" }
collectionJob.cancel()
fun testInitialization() {
assertNotNull(viewModel)
}
*/
@Test
fun `state reflects updates from getNodeDetailsUseCase`() = runTest(testDispatcher) {
val nodeDetailFlow = MutableStateFlow(NodeDetailUiState())
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val vm = createViewModel()
vm.state.test {
assertEquals(MetricsState.Empty, awaitItem())
val newState = MetricsState(isFahrenheit = true)
nodeDetailFlow.value = NodeDetailUiState(metricsState = newState)
assertEquals(newState, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `availableTimeFrames filters based on oldest data`() = runTest(testDispatcher) {
val now = org.meshtastic.core.common.util.nowSeconds
val nodeDetailFlow = MutableStateFlow(NodeDetailUiState())
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val vm = createViewModel()
vm.availableTimeFrames.test {
// Skip initial values
var current = awaitItem()
// Provide data from 2 hours ago (7200 seconds)
// This should make ONE_HOUR available, but not TWENTY_FOUR_HOURS
val twoHoursAgo = now - 7200
nodeDetailFlow.value = NodeDetailUiState(
node = org.meshtastic.core.model.Node(num = 1234, user = org.meshtastic.proto.User(id = "!1234")),
environmentState = EnvironmentMetricsState(
environmentMetrics = listOf(Telemetry(time = twoHoursAgo.toInt()))
)
)
// We might get multiple emissions as flows propagate
current = awaitItem()
while (current.size == TimeFrame.entries.size) { // Skip the initial "all" if it's still there
current = awaitItem()
}
assertTrue(current.contains(TimeFrame.ONE_HOUR))
assertTrue(!current.contains(TimeFrame.TWENTY_FOUR_HOURS), "Should not contain 24h for 2h old data")
// Provide data from 8 days ago
val eightDaysAgo = now - (8 * 24 * 3600)
nodeDetailFlow.value = NodeDetailUiState(
node = org.meshtastic.core.model.Node(num = 1234, user = org.meshtastic.proto.User(id = "!1234")),
environmentState = EnvironmentMetricsState(
environmentMetrics = listOf(Telemetry(time = eightDaysAgo.toInt()))
)
)
current = awaitItem()
assertTrue(current.contains(TimeFrame.SEVEN_DAYS))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `savePositionCSV writes correct data`() = runTest(testDispatcher) {
val testPosition = Position(
latitude_i = 123456789,
longitude_i = -987654321,
altitude = 100,
sats_in_view = 5,
ground_speed = 10,
ground_track = 123456,
time = 1700000000,
)
val nodeDetailFlow = MutableStateFlow(NodeDetailUiState(
metricsState = MetricsState(positionLogs = listOf(testPosition))
))
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val buffer = Buffer()
everySuspend { fileService.write(any(), any()) } calls { args ->
val block = args.arg<suspend (BufferedSink) -> Unit>(1)
block(buffer)
true
}
val vm = createViewModel()
// Wait for state to be collected so it's not Empty when savePositionCSV is called
vm.state.test {
awaitItem() // Empty
awaitItem() // with position
val uri = MeshtasticUri("content://test")
vm.savePositionCSV(uri)
runCurrent()
verifySuspend { fileService.write(uri, any()) }
val csvOutput = buffer.readUtf8()
assertTrue(csvOutput.startsWith("\"date\",\"time\",\"latitude\",\"longitude\""))
assertTrue(csvOutput.contains("12.3456789"))
assertTrue(csvOutput.contains("-98.7654321"))
assertTrue(csvOutput.contains("\"100\",\"5\",\"10\",\"1.23\""))
cancelAndIgnoreRemainingEvents()
}
}
}