feat: Enhance test coverage (#4847)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-18 22:09:19 -05:00 committed by GitHub
parent 1b0dc75dfe
commit 06b9f8c77a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1715 additions and 502 deletions

View file

@ -0,0 +1,136 @@
/*
* 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(binds = [NodeRequestActions::class])
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,35 @@
*/
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) {
/** 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>>
private val _effects = MutableSharedFlow<NodeRequestEffect>()
val effects: SharedFlow<NodeRequestEffect> = _effects.asSharedFlow()
fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String)
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),
),
)
}
}
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)
fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType)
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 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(binds = [GetNodeDetailsUseCase::class])
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,10 @@
*/
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,6 @@ 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.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
@ -55,6 +54,7 @@ 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.core.repository.TracerouteSnapshotRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.traceroute

View file

@ -0,0 +1,139 @@
/*
* 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.feature.node.compass
import app.cash.turbine.test
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.Node
import org.meshtastic.proto.Config
import org.meshtastic.proto.User
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class CompassViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
private lateinit var viewModel: CompassViewModel
private val headingProvider: CompassHeadingProvider = mock()
private val phoneLocationProvider: PhoneLocationProvider = mock()
private val magneticFieldProvider: MagneticFieldProvider = mock()
private val headingFlow = MutableStateFlow(HeadingState())
private val locationFlow = MutableStateFlow(PhoneLocationState(permissionGranted = true, providerEnabled = true))
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { headingProvider.headingUpdates() } returns headingFlow
every { phoneLocationProvider.locationUpdates() } returns locationFlow
every { magneticFieldProvider.getDeclination(any(), any(), any(), any()) } returns 0f
viewModel =
CompassViewModel(
headingProvider = headingProvider,
phoneLocationProvider = phoneLocationProvider,
magneticFieldProvider = magneticFieldProvider,
dispatchers = dispatchers,
)
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testInitialization() {
assertNotNull(viewModel)
}
@Test
fun `uiState reflects target node info after start`() = runTest {
val node = Node(num = 1234, user = User(id = "!1234", long_name = "Target Node"))
viewModel.start(node, Config.DisplayConfig.DisplayUnits.METRIC)
viewModel.uiState.test {
val state = awaitItem()
assertEquals("Target Node", state.targetName)
assertEquals(Config.DisplayConfig.DisplayUnits.METRIC, state.displayUnits)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `uiState updates when heading and location change`() = runTest {
val node =
Node(
num = 1234,
user = User(id = "!1234"),
position =
org.meshtastic.proto.Position(
latitude_i = 10000000,
longitude_i = 10000000,
), // 1 deg North, 1 deg East
)
viewModel.start(node, Config.DisplayConfig.DisplayUnits.METRIC)
viewModel.uiState.test {
// Skip initial states
awaitItem()
// Update location and heading
locationFlow.value =
PhoneLocationState(
permissionGranted = true,
providerEnabled = true,
location = PhoneLocation(0.0, 0.0, 0.0, 1000L),
)
headingFlow.value = HeadingState(heading = 0f)
// Wait for state with both bearing and heading
var state = awaitItem()
while (state.bearing == null || state.heading == null) {
state = awaitItem()
}
// Bearing from (0,0) to (1,1) is approx 45 degrees
assertEquals(45f, state.bearing!!, 0.5f)
assertEquals(0f, state.heading!!, 0.1f)
assertTrue(state.hasTargetPosition)
cancelAndIgnoreRemainingEvents()
}
}
}

View file

@ -0,0 +1,118 @@
/*
* 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.feature.node.detail
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.proto.User
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@OptIn(ExperimentalCoroutinesApi::class)
class NodeDetailViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var viewModel: NodeDetailViewModel
private val nodeManagementActions: NodeManagementActions = mock()
private val nodeRequestActions: NodeRequestActions = mock()
private val serviceRepository: ServiceRepository = mock()
private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock()
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { getNodeDetailsUseCase(any()) } returns emptyFlow()
every { nodeRequestActions.effects } returns kotlinx.coroutines.flow.MutableSharedFlow()
viewModel = createViewModel(1234)
}
private fun createViewModel(nodeId: Int?) = NodeDetailViewModel(
savedStateHandle = SavedStateHandle(if (nodeId != null) mapOf("destNum" to nodeId) else emptyMap()),
nodeManagementActions = nodeManagementActions,
nodeRequestActions = nodeRequestActions,
serviceRepository = serviceRepository,
getNodeDetailsUseCase = getNodeDetailsUseCase,
)
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testInitialization() {
assertNotNull(viewModel)
}
@Test
fun `uiState emits updates from useCase`() = runTest(testDispatcher) {
val node = Node(num = 1234, user = User(id = "!1234"))
val stateFlow = MutableStateFlow(NodeDetailUiState(node = node))
every { getNodeDetailsUseCase(1234) } returns stateFlow
val vm = createViewModel(1234)
vm.uiState.test {
// State from useCase (delivered immediately due to UnconfinedTestDispatcher)
val state = awaitItem()
assertEquals(1234, state.node?.num)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `handleNodeMenuAction delegates to nodeManagementActions for Mute`() = runTest(testDispatcher) {
val node = Node(num = 1234, user = User(id = "!1234"))
every { nodeManagementActions.requestMuteNode(any(), any()) } returns Unit
viewModel.handleNodeMenuAction(NodeMenuAction.Mute(node))
verify { nodeManagementActions.requestMuteNode(any(), node) }
}
@Test
fun `handleNodeMenuAction delegates to nodeRequestActions for Traceroute`() = runTest(testDispatcher) {
val node = Node(num = 1234, user = User(id = "!1234", long_name = "Test Node"))
every { nodeRequestActions.requestTraceroute(any(), any(), any()) } returns Unit
viewModel.handleNodeMenuAction(NodeMenuAction.TraceRoute(node))
verify { nodeRequestActions.requestTraceroute(any(), 1234, "Test Node") }
}
}

View file

@ -16,28 +16,29 @@
*/
package org.meshtastic.feature.node.domain.usecase
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.feature.node.list.NodeFilterState
import org.meshtastic.proto.Config
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class GetFilteredNodesUseCaseTest {
private lateinit var nodeRepository: NodeRepository
private lateinit var useCase: GetFilteredNodesUseCase
@Before
@BeforeTest
fun setUp() {
nodeRepository = mock()
useCase = GetFilteredNodesUseCase(nodeRepository)

View file

@ -16,46 +16,172 @@
*/
package org.meshtastic.feature.node.metrics
class MetricsViewModelTest {
/*
import app.cash.turbine.test
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.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.core.repository.TracerouteSnapshotRepository
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 testInitialization() {
assertNotNull(viewModel)
}
@Test
fun testSavePositionCSV() = runTest {
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,
@ -67,52 +193,37 @@ class MetricsViewModelTest {
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 nodeDetailFlow =
MutableStateFlow(NodeDetailUiState(metricsState = MetricsState(positionLogs = listOf(testPosition))))
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val buffer = Buffer()
blockSlot.captured.invoke(buffer)
everySuspend { fileService.write(any(), any()) } calls
{ args ->
val block = args.arg<suspend (BufferedSink) -> Unit>(1)
block(buffer)
true
}
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" }
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
collectionJob.cancel()
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()
}
}
*/
}