From 79e059286204428d75165c85ce7746f6c9a08452 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:03:52 -0500 Subject: [PATCH] test(node): Refactor MetricsViewModelTest and extract interfaces for mocking --- conductor/tracks.md | 2 +- .../tracks/expand_testing_20260318/plan.md | 2 +- .../data/manager/TracerouteHandlerImpl.kt | 2 +- ...kt => TracerouteSnapshotRepositoryImpl.kt} | 9 +- .../TracerouteSnapshotRepository.kt | 35 +++ .../meshtastic/core/ui/util/AlertManager.kt | 6 +- .../node/detail/CommonNodeRequestActions.kt | 141 +++++++++ .../feature/node/detail/NodeRequestActions.kt | 123 +------- .../usecase/CommonGetNodeDetailsUseCase.kt | 237 +++++++++++++++ .../domain/usecase/GetNodeDetailsUseCase.kt | 222 +------------- .../feature/node/metrics/MetricsViewModel.kt | 2 +- .../node/metrics/MetricsViewModelTest.kt | 279 ++++++++++++------ 12 files changed, 639 insertions(+), 421 deletions(-) rename core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/{TracerouteSnapshotRepository.kt => TracerouteSnapshotRepositoryImpl.kt} (86%) create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteSnapshotRepository.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt diff --git a/conductor/tracks.md b/conductor/tracks.md index 15a09815c..02c666352 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -3,6 +3,6 @@ This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder. --- -- [ ] **Track: Expand Testing Coverage** +- [~] **Track: Expand Testing Coverage** *Link: [./tracks/expand_testing_20260318/](./tracks/expand_testing_20260318/)* diff --git a/conductor/tracks/expand_testing_20260318/plan.md b/conductor/tracks/expand_testing_20260318/plan.md index 93e0eec36..f1410c0a7 100644 --- a/conductor/tracks/expand_testing_20260318/plan.md +++ b/conductor/tracks/expand_testing_20260318/plan.md @@ -5,7 +5,7 @@ - [x] Task: Conductor - User Manual Verification 'Phase 1: Baseline Measurement' (Protocol in workflow.md) 6d9ad468c ## Phase 2: Feature ViewModel Migration to Turbine -- [ ] Task: Refactor `MetricsViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. +- [~] Task: Refactor `MetricsViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. - [ ] Task: Refactor `MessageViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. - [ ] Task: Refactor `RadioConfigViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. - [ ] Task: Refactor `NodeListViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt index 2cc22e8f1..8f295d4f4 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt @@ -24,7 +24,7 @@ import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.data.repository.TracerouteSnapshotRepository +import org.meshtastic.core.repository.TracerouteSnapshotRepository import org.meshtastic.core.model.Node import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.model.getFullTracerouteResponse diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepository.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt similarity index 86% rename from core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepository.kt rename to core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt index 27f38a56f..c4712967f 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepository.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt @@ -27,22 +27,23 @@ import org.koin.core.annotation.Single import org.meshtastic.core.database.DatabaseProvider import org.meshtastic.core.database.entity.TracerouteNodePositionEntity import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.repository.TracerouteSnapshotRepository import org.meshtastic.proto.Position @Single -class TracerouteSnapshotRepository( +class TracerouteSnapshotRepositoryImpl( private val dbManager: DatabaseProvider, private val dispatchers: CoroutineDispatchers, -) { +) : TracerouteSnapshotRepository { - fun getSnapshotPositions(logUuid: String): Flow> = dbManager.currentDb + override fun getSnapshotPositions(logUuid: String): Flow> = dbManager.currentDb .flatMapLatest { it.tracerouteNodePositionDao().getByLogUuid(logUuid) } .distinctUntilChanged() .mapLatest { list -> list.associate { it.nodeNum to it.position } } .flowOn(dispatchers.io) .conflate() - suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map) = + override suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map) = withContext(dispatchers.io) { val dao = dbManager.currentDb.value.tracerouteNodePositionDao() dao.deleteByLogUuid(logUuid) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteSnapshotRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteSnapshotRepository.kt new file mode 100644 index 000000000..f9829e36b --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteSnapshotRepository.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.proto.Position + +/** + * Repository interface for managing snapshots of traceroute results. + */ +interface TracerouteSnapshotRepository { + /** + * Returns a reactive flow of positions associated with a specific traceroute log. + */ + fun getSnapshotPositions(logUuid: String): Flow> + + /** + * Persists a set of positions for a traceroute log. + */ + suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map) +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/AlertManager.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/AlertManager.kt index db369fe82..a5398a66b 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/AlertManager.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/AlertManager.kt @@ -52,9 +52,9 @@ open class AlertManager { ) private val _currentAlert = MutableStateFlow(null) - val currentAlert = _currentAlert.asStateFlow() + open val currentAlert = _currentAlert.asStateFlow() - fun showAlert( + open fun showAlert( title: String? = null, titleRes: StringResource? = null, message: String? = null, @@ -97,7 +97,7 @@ open class AlertManager { ) } - fun dismissAlert() { + open fun dismissAlert() { _currentAlert.value = null } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt new file mode 100644 index 000000000..9519566c8 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.detail + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.model.Position +import org.meshtastic.core.model.RadioController +import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.UiText +import org.meshtastic.core.resources.neighbor_info +import org.meshtastic.core.resources.position +import org.meshtastic.core.resources.request_air_quality_metrics +import org.meshtastic.core.resources.request_device_metrics +import org.meshtastic.core.resources.request_environment_metrics +import org.meshtastic.core.resources.request_host_metrics +import org.meshtastic.core.resources.request_pax_metrics +import org.meshtastic.core.resources.request_power_metrics +import org.meshtastic.core.resources.requesting_from +import org.meshtastic.core.resources.signal_quality +import org.meshtastic.core.resources.traceroute +import org.meshtastic.core.resources.user_info + +@Single +class CommonNodeRequestActions constructor(private val radioController: RadioController) : NodeRequestActions { + + private val _effects = MutableSharedFlow() + override val effects: SharedFlow = _effects.asSharedFlow() + + private val _lastTracerouteTime = MutableStateFlow(null) + override val lastTracerouteTime: StateFlow = _lastTracerouteTime.asStateFlow() + + private val _lastRequestNeighborTimes = MutableStateFlow>(emptyMap()) + override val lastRequestNeighborTimes: StateFlow> = _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), + ), + ) + } + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt index 45bfb95a5..1c8ed52c1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * Copyright (c) 2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,130 +16,33 @@ */ package org.meshtastic.feature.node.detail -import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Position -import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.TelemetryType -import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText -import org.meshtastic.core.resources.neighbor_info -import org.meshtastic.core.resources.position -import org.meshtastic.core.resources.request_air_quality_metrics -import org.meshtastic.core.resources.request_device_metrics -import org.meshtastic.core.resources.request_environment_metrics -import org.meshtastic.core.resources.request_host_metrics -import org.meshtastic.core.resources.request_pax_metrics -import org.meshtastic.core.resources.request_power_metrics -import org.meshtastic.core.resources.requesting_from -import org.meshtastic.core.resources.signal_quality -import org.meshtastic.core.resources.traceroute -import org.meshtastic.core.resources.user_info sealed class NodeRequestEffect { data class ShowFeedback(val text: UiText) : NodeRequestEffect() } -@Single -class NodeRequestActions constructor(private val radioController: RadioController) { - - private val _effects = MutableSharedFlow() - val effects: SharedFlow = _effects.asSharedFlow() - - private val _lastTracerouteTime = MutableStateFlow(null) - val lastTracerouteTime: StateFlow = _lastTracerouteTime.asStateFlow() - - private val _lastRequestNeighborTimes = MutableStateFlow>(emptyMap()) - val lastRequestNeighborTimes: StateFlow> = _lastRequestNeighborTimes.asStateFlow() - - fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) { - scope.launch(Dispatchers.IO) { - Logger.i { "Requesting UserInfo for '$destNum'" } - radioController.requestUserInfo(destNum) - _effects.emit( - NodeRequestEffect.ShowFeedback( - UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName), - ), - ) - } - } - - fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) { - scope.launch(Dispatchers.IO) { - Logger.i { "Requesting NeighborInfo for '$destNum'" } - val packetId = radioController.getPacketId() - radioController.requestNeighborInfo(packetId, destNum) - _lastRequestNeighborTimes.update { it + (destNum to nowMillis) } - _effects.emit( - NodeRequestEffect.ShowFeedback( - UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName), - ), - ) - } - } +/** + * Interface for high-level node request actions (e.g., requesting user info, position, telemetry). + */ +interface NodeRequestActions { + val effects: SharedFlow + val lastTracerouteTime: StateFlow + val lastRequestNeighborTimes: StateFlow> + fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) + fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) fun requestPosition( scope: CoroutineScope, destNum: Int, longName: String, position: Position = Position(0.0, 0.0, 0), - ) { - scope.launch(Dispatchers.IO) { - Logger.i { "Requesting position for '$destNum'" } - radioController.requestPosition(destNum, position) - _effects.emit( - NodeRequestEffect.ShowFeedback( - UiText.Resource(Res.string.requesting_from, Res.string.position, longName), - ), - ) - } - } - - fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) { - scope.launch(Dispatchers.IO) { - Logger.i { "Requesting telemetry for '$destNum'" } - val packetId = radioController.getPacketId() - radioController.requestTelemetry(packetId, destNum, type.ordinal) - - val typeRes = - when (type) { - TelemetryType.DEVICE -> Res.string.request_device_metrics - TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics - TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics - TelemetryType.POWER -> Res.string.request_power_metrics - TelemetryType.LOCAL_STATS -> Res.string.signal_quality - TelemetryType.HOST -> Res.string.request_host_metrics - TelemetryType.PAX -> Res.string.request_pax_metrics - } - - _effects.emit( - NodeRequestEffect.ShowFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)), - ) - } - } - - fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) { - scope.launch(Dispatchers.IO) { - Logger.i { "Requesting traceroute for '$destNum'" } - val packetId = radioController.getPacketId() - radioController.requestTraceroute(packetId, destNum) - _lastTracerouteTime.value = nowMillis - _effects.emit( - NodeRequestEffect.ShowFeedback( - UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName), - ), - ) - } - } + ) + fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) + fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt new file mode 100644 index 000000000..5fb565af3 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt @@ -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 . + */ +package org.meshtastic.feature.node.domain.usecase + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import org.koin.core.annotation.Single +import org.meshtastic.core.data.repository.FirmwareReleaseRepository +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.model.MeshLog +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.util.hasValidEnvironmentMetrics +import org.meshtastic.core.model.util.isDirectSignal +import org.meshtastic.core.repository.DeviceHardwareRepository +import org.meshtastic.core.repository.MeshLogRepository +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.UiText +import org.meshtastic.core.resources.fallback_node_name +import org.meshtastic.core.ui.util.toPosition +import org.meshtastic.feature.node.detail.NodeDetailUiState +import org.meshtastic.feature.node.detail.NodeRequestActions +import org.meshtastic.feature.node.metrics.EnvironmentMetricsState +import org.meshtastic.feature.node.model.LogsType +import org.meshtastic.feature.node.model.MetricsState +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceProfile +import org.meshtastic.proto.FirmwareEdition +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Telemetry + +@Single +class CommonGetNodeDetailsUseCase +constructor( + private val nodeRepository: NodeRepository, + private val meshLogRepository: MeshLogRepository, + private val radioConfigRepository: RadioConfigRepository, + private val deviceHardwareRepository: DeviceHardwareRepository, + private val firmwareReleaseRepository: FirmwareReleaseRepository, + private val nodeRequestActions: NodeRequestActions, +) : GetNodeDetailsUseCase { + + @OptIn(ExperimentalCoroutinesApi::class) + @Suppress("LongMethod", "CyclomaticComplexMethod") + override operator fun invoke(nodeId: Int): Flow = + nodeRepository.effectiveLogNodeId(nodeId).flatMapLatest { effectiveNodeId -> + buildFlow(nodeId, effectiveNodeId) + } + + @Suppress("LongMethod", "CyclomaticComplexMethod") + private fun buildFlow(nodeId: Int, effectiveNodeId: Int): Flow { + 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> -> + @Suppress("UNCHECKED_CAST") + LogsGroup( + telemetry = args[0] as List, + packets = args[1] as List, + posPackets = args[2] as List, + pax = args[3] as List, + trRes = args[4] as List, + niRes = args[5] as List, + ) + } + + // 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, + val packets: List, + val posPackets: List, + val pax: List, + val trRes: List, + val niRes: List, + ) + + 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?, + ) +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/GetNodeDetailsUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/GetNodeDetailsUseCase.kt index 8467237f1..61531c88a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/GetNodeDetailsUseCase.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/GetNodeDetailsUseCase.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * Copyright (c) 2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,222 +16,12 @@ */ package org.meshtastic.feature.node.domain.usecase -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import org.koin.core.annotation.Single -import org.meshtastic.core.data.repository.FirmwareReleaseRepository -import org.meshtastic.core.database.entity.FirmwareRelease -import org.meshtastic.core.model.MeshLog -import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.util.hasValidEnvironmentMetrics -import org.meshtastic.core.model.util.isDirectSignal -import org.meshtastic.core.repository.DeviceHardwareRepository -import org.meshtastic.core.repository.MeshLogRepository -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.UiText -import org.meshtastic.core.resources.fallback_node_name -import org.meshtastic.core.ui.util.toPosition import org.meshtastic.feature.node.detail.NodeDetailUiState -import org.meshtastic.feature.node.detail.NodeRequestActions -import org.meshtastic.feature.node.metrics.EnvironmentMetricsState -import org.meshtastic.feature.node.model.LogsType -import org.meshtastic.feature.node.model.MetricsState -import org.meshtastic.proto.Config -import org.meshtastic.proto.DeviceProfile -import org.meshtastic.proto.FirmwareEdition -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.Telemetry -@Single -class GetNodeDetailsUseCase -constructor( - private val nodeRepository: NodeRepository, - private val meshLogRepository: MeshLogRepository, - private val radioConfigRepository: RadioConfigRepository, - private val deviceHardwareRepository: DeviceHardwareRepository, - private val firmwareReleaseRepository: FirmwareReleaseRepository, - private val nodeRequestActions: NodeRequestActions, -) { - - @OptIn(ExperimentalCoroutinesApi::class) - @Suppress("LongMethod", "CyclomaticComplexMethod") - operator fun invoke(nodeId: Int): Flow = - nodeRepository.effectiveLogNodeId(nodeId).flatMapLatest { effectiveNodeId -> - buildFlow(nodeId, effectiveNodeId) - } - - @Suppress("LongMethod", "CyclomaticComplexMethod") - private fun buildFlow(nodeId: Int, effectiveNodeId: Int): Flow { - 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> -> - @Suppress("UNCHECKED_CAST") - LogsGroup( - telemetry = args[0] as List, - packets = args[1] as List, - posPackets = args[2] as List, - pax = args[3] as List, - trRes = args[4] as List, - niRes = args[5] as List, - ) - } - - // 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, - val packets: List, - val posPackets: List, - val pax: List, - val trRes: List, - val niRes: List, - ) - - 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 } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 438afcaa7..f12ff0023 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -44,7 +44,7 @@ import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.data.repository.TracerouteSnapshotRepository +import org.meshtastic.core.repository.TracerouteSnapshotRepository import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.Node diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt index 33f7ccd8f..3f7284546 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt @@ -16,103 +16,214 @@ */ package org.meshtastic.feature.node.metrics -class MetricsViewModelTest { - /* +import app.cash.turbine.test +import dev.mokkery.MockMode +import dev.mokkery.answering.calls +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verifySuspend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import okio.Buffer +import okio.BufferedSink +import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.repository.TracerouteSnapshotRepository +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.repository.FileService +import org.meshtastic.core.repository.MeshLogRepository +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.feature.node.detail.NodeDetailUiState +import org.meshtastic.feature.node.detail.NodeRequestActions +import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase +import org.meshtastic.feature.node.model.MetricsState +import org.meshtastic.feature.node.model.TimeFrame +import org.meshtastic.proto.Position +import org.meshtastic.proto.Telemetry +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue - private val dispatchers = - CoroutineDispatchers( - main = kotlinx.coroutines.Dispatchers.Unconfined, - io = kotlinx.coroutines.Dispatchers.Unconfined, - default = kotlinx.coroutines.Dispatchers.Unconfined, - ) +class MetricsViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private val dispatchers = CoroutineDispatchers( + main = testDispatcher, + io = testDispatcher, + default = testDispatcher, + ) + + private val meshLogRepository: MeshLogRepository = mock() + private val serviceRepository: ServiceRepository = mock() + private val nodeRepository: NodeRepository = mock() + private val tracerouteSnapshotRepository: TracerouteSnapshotRepository = mock() + private val nodeRequestActions: NodeRequestActions = mock() + private val alertManager: org.meshtastic.core.ui.util.AlertManager = mock() + private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock() + private val fileService: FileService = mock() private lateinit var viewModel: MetricsViewModel - @Before + @BeforeTest fun setUp() { - Dispatchers.setMain(dispatchers.main) + Dispatchers.setMain(testDispatcher) - viewModel = - MetricsViewModel( - destNum = 1234, - dispatchers = dispatchers, - meshLogRepository = meshLogRepository, - serviceRepository = serviceRepository, - nodeRepository = nodeRepository, - tracerouteSnapshotRepository = tracerouteSnapshotRepository, - nodeRequestActions = nodeRequestActions, - alertManager = alertManager, - getNodeDetailsUseCase = getNodeDetailsUseCase, - fileService = fileService, - ) + // Default setup for flows + every { serviceRepository.tracerouteResponse } returns MutableStateFlow(null) + every { nodeRequestActions.effects } returns mock() + every { nodeRequestActions.lastTracerouteTime } returns MutableStateFlow(null) + every { nodeRequestActions.lastRequestNeighborTimes } returns MutableStateFlow(emptyMap()) + every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap()) + + // Mock the case where we get node details + every { getNodeDetailsUseCase(any()) } returns flowOf(NodeDetailUiState()) + + viewModel = createViewModel() } - @After + private fun createViewModel(destNum: Int = 1234) = MetricsViewModel( + destNum = destNum, + dispatchers = dispatchers, + meshLogRepository = meshLogRepository, + serviceRepository = serviceRepository, + nodeRepository = nodeRepository, + tracerouteSnapshotRepository = tracerouteSnapshotRepository, + nodeRequestActions = nodeRequestActions, + alertManager = alertManager, + getNodeDetailsUseCase = getNodeDetailsUseCase, + fileService = fileService, + ) + + @AfterTest fun tearDown() { Dispatchers.resetMain() } - @Test fun testInitialization() = runTest { assertNotNull(viewModel) } - @Test - fun testSavePositionCSV() = runTest { - val testPosition = - Position( - latitude_i = 123456789, - longitude_i = -987654321, - altitude = 100, - sats_in_view = 5, - ground_speed = 10, - ground_track = 123456, - time = 1700000000, - ) - - everySuspend { getNodeDetailsUseCase(any()) } returns - flowOf(NodeDetailUiState(metricsState = MetricsState(positionLogs = listOf(testPosition)))) - - // Re-init view model so it picks up the mocked flow - viewModel = - MetricsViewModel( - destNum = 1234, - dispatchers = dispatchers, - meshLogRepository = meshLogRepository, - serviceRepository = serviceRepository, - nodeRepository = nodeRepository, - tracerouteSnapshotRepository = tracerouteSnapshotRepository, - nodeRequestActions = nodeRequestActions, - alertManager = alertManager, - getNodeDetailsUseCase = getNodeDetailsUseCase, - fileService = fileService, - ) - - // Wait for state to populate - val collectionJob = backgroundScope.launch { viewModel.state.collect {} } - kotlinx.coroutines.yield() - advanceUntilIdle() - - val uri = MeshtasticUri("content://test") - - - viewModel.savePositionCSV(uri) - - advanceUntilIdle() - - verifySuspend { fileService.write(uri, any()) } - - val buffer = Buffer() - blockSlot.captured.invoke(buffer) - - val csvOutput = buffer.readUtf8() - assertEquals( - "\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"\n", - csvOutput.substringBefore("\n") + "\n", - ) - assert(csvOutput.contains("12.345")) { "Missing latitude in $csvOutput" } - assert(csvOutput.contains("-98.765")) { "Missing longitude in $csvOutput" } - assert(csvOutput.contains("\"100\",\"5\",\"10\",\"1.23\"\n")) { "Missing rest in $csvOutput" } - - collectionJob.cancel() + fun testInitialization() { + assertNotNull(viewModel) } - */ + @Test + fun `state reflects updates from getNodeDetailsUseCase`() = runTest(testDispatcher) { + val nodeDetailFlow = MutableStateFlow(NodeDetailUiState()) + every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow() + + val vm = createViewModel() + vm.state.test { + assertEquals(MetricsState.Empty, awaitItem()) + + val newState = MetricsState(isFahrenheit = true) + nodeDetailFlow.value = NodeDetailUiState(metricsState = newState) + + assertEquals(newState, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `availableTimeFrames filters based on oldest data`() = runTest(testDispatcher) { + val now = org.meshtastic.core.common.util.nowSeconds + + val nodeDetailFlow = MutableStateFlow(NodeDetailUiState()) + every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow() + + val vm = createViewModel() + vm.availableTimeFrames.test { + // Skip initial values + var current = awaitItem() + + // Provide data from 2 hours ago (7200 seconds) + // This should make ONE_HOUR available, but not TWENTY_FOUR_HOURS + val twoHoursAgo = now - 7200 + nodeDetailFlow.value = NodeDetailUiState( + node = org.meshtastic.core.model.Node(num = 1234, user = org.meshtastic.proto.User(id = "!1234")), + environmentState = EnvironmentMetricsState( + environmentMetrics = listOf(Telemetry(time = twoHoursAgo.toInt())) + ) + ) + + // We might get multiple emissions as flows propagate + current = awaitItem() + while (current.size == TimeFrame.entries.size) { // Skip the initial "all" if it's still there + current = awaitItem() + } + + assertTrue(current.contains(TimeFrame.ONE_HOUR)) + assertTrue(!current.contains(TimeFrame.TWENTY_FOUR_HOURS), "Should not contain 24h for 2h old data") + + // Provide data from 8 days ago + val eightDaysAgo = now - (8 * 24 * 3600) + nodeDetailFlow.value = NodeDetailUiState( + node = org.meshtastic.core.model.Node(num = 1234, user = org.meshtastic.proto.User(id = "!1234")), + environmentState = EnvironmentMetricsState( + environmentMetrics = listOf(Telemetry(time = eightDaysAgo.toInt())) + ) + ) + + current = awaitItem() + assertTrue(current.contains(TimeFrame.SEVEN_DAYS)) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `savePositionCSV writes correct data`() = runTest(testDispatcher) { + val testPosition = Position( + latitude_i = 123456789, + longitude_i = -987654321, + altitude = 100, + sats_in_view = 5, + ground_speed = 10, + ground_track = 123456, + time = 1700000000, + ) + + val nodeDetailFlow = MutableStateFlow(NodeDetailUiState( + metricsState = MetricsState(positionLogs = listOf(testPosition)) + )) + every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow() + + val buffer = Buffer() + everySuspend { fileService.write(any(), any()) } calls { args -> + val block = args.arg Unit>(1) + block(buffer) + true + } + + val vm = createViewModel() + // Wait for state to be collected so it's not Empty when savePositionCSV is called + vm.state.test { + awaitItem() // Empty + awaitItem() // with position + + val uri = MeshtasticUri("content://test") + vm.savePositionCSV(uri) + runCurrent() + + verifySuspend { fileService.write(uri, any()) } + + val csvOutput = buffer.readUtf8() + assertTrue(csvOutput.startsWith("\"date\",\"time\",\"latitude\",\"longitude\"")) + assertTrue(csvOutput.contains("12.3456789")) + assertTrue(csvOutput.contains("-98.7654321")) + assertTrue(csvOutput.contains("\"100\",\"5\",\"10\",\"1.23\"")) + + cancelAndIgnoreRemainingEvents() + } + } }