mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Enhance test coverage (#4847)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
1b0dc75dfe
commit
06b9f8c77a
41 changed files with 1715 additions and 502 deletions
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue