From 06b9f8c77aace1e1aa3d09cec6fcaeb1c2bb9165 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:09:19 -0500 Subject: [PATCH] feat: Enhance test coverage (#4847) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .github/workflows/reusable-check.yml | 1 + AGENTS.md | 2 +- GEMINI.md | 2 +- .../expand_testing_20260318/index.md | 0 .../expand_testing_20260318/metadata.json | 0 .../archive/expand_testing_20260318/plan.md | 32 +++ .../expand_testing_20260318/spec.md | 0 conductor/tech-stack.md | 2 +- conductor/tracks.md | 4 - .../tracks/expand_testing_20260318/plan.md | 32 --- core/data/build.gradle.kts | 2 + .../data/manager/TracerouteHandlerImpl.kt | 2 +- ...kt => TracerouteSnapshotRepositoryImpl.kt} | 9 +- .../data/manager/PacketHandlerImplTest.kt | 22 +- .../usecase/settings/SetLocaleUseCaseTest.kt | 44 ++++ .../SetNotificationSettingsUseCaseTest.kt | 58 ++++ core/network/build.gradle.kts | 6 +- .../core/network/radio/StreamInterfaceTest.kt | 93 +++++++ .../network/transport/StreamFrameCodecTest.kt | 33 +++ .../TracerouteSnapshotRepository.kt | 29 ++ core/ui/build.gradle.kts | 2 + .../meshtastic/core/ui/util/AlertManager.kt | 6 +- .../core/ui/emoji/EmojiPickerViewModelTest.kt | 58 ++++ .../ui/share/SharedContactViewModelTest.kt | 96 +++++++ .../ui/viewmodel/ConnectionsViewModelTest.kt | 91 +++++++ docs/testing/baseline_coverage.md | 6 + docs/testing/final_coverage.md | 18 ++ .../feature/intro/IntroViewModelTest.kt | 22 +- .../feature/map/BaseMapViewModelTest.kt | 107 +++++--- .../feature/messaging/MessageViewModelTest.kt | 18 +- .../messaging/QuickChatViewModelTest.kt | 93 +++++++ .../ui/contact/ContactsViewModelTest.kt | 100 +++++++ .../node/detail/CommonNodeRequestActions.kt | 136 ++++++++++ .../feature/node/detail/NodeRequestActions.kt | 117 +------- .../usecase/CommonGetNodeDetailsUseCase.kt | 237 +++++++++++++++++ .../domain/usecase/GetNodeDetailsUseCase.kt | 220 +--------------- .../feature/node/metrics/MetricsViewModel.kt | 2 +- .../node/compass/CompassViewModelTest.kt | 139 ++++++++++ .../node/detail/NodeDetailViewModelTest.kt | 118 +++++++++ .../usecase/GetFilteredNodesUseCaseTest.kt | 9 +- .../node/metrics/MetricsViewModelTest.kt | 249 +++++++++++++----- 41 files changed, 1715 insertions(+), 502 deletions(-) rename conductor/{tracks => archive}/expand_testing_20260318/index.md (100%) rename conductor/{tracks => archive}/expand_testing_20260318/metadata.json (100%) create mode 100644 conductor/archive/expand_testing_20260318/plan.md rename conductor/{tracks => archive}/expand_testing_20260318/spec.md (100%) delete mode 100644 conductor/tracks/expand_testing_20260318/plan.md rename core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/{TracerouteSnapshotRepository.kt => TracerouteSnapshotRepositoryImpl.kt} (86%) create mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCaseTest.kt create mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCaseTest.kt create mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.kt create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteSnapshotRepository.kt create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerViewModelTest.kt create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt create mode 100644 docs/testing/baseline_coverage.md create mode 100644 docs/testing/final_coverage.md create mode 100644 feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/QuickChatViewModelTest.kt create mode 100644 feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.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 create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt rename feature/node/src/{test => commonTest}/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt (97%) diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index 13c70b80a..ccc7cff5e 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -256,3 +256,4 @@ jobs: path: | **/build/outputs/androidTest-results retention-days: 7 + if-no-files-found: ignore diff --git a/AGENTS.md b/AGENTS.md index def726573..f4a27a065 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`. - **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main. - **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`. -- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `core:testing` shared fakes. +- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. Use `core:testing` shared fakes. - **Build-logic conventions:** In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative. ### C. Namespacing diff --git a/GEMINI.md b/GEMINI.md index def726573..f4a27a065 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -77,7 +77,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`. - **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main. - **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`. -- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `core:testing` shared fakes. +- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. Use `core:testing` shared fakes. - **Build-logic conventions:** In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative. ### C. Namespacing diff --git a/conductor/tracks/expand_testing_20260318/index.md b/conductor/archive/expand_testing_20260318/index.md similarity index 100% rename from conductor/tracks/expand_testing_20260318/index.md rename to conductor/archive/expand_testing_20260318/index.md diff --git a/conductor/tracks/expand_testing_20260318/metadata.json b/conductor/archive/expand_testing_20260318/metadata.json similarity index 100% rename from conductor/tracks/expand_testing_20260318/metadata.json rename to conductor/archive/expand_testing_20260318/metadata.json diff --git a/conductor/archive/expand_testing_20260318/plan.md b/conductor/archive/expand_testing_20260318/plan.md new file mode 100644 index 000000000..e6bd01565 --- /dev/null +++ b/conductor/archive/expand_testing_20260318/plan.md @@ -0,0 +1,32 @@ +# Implementation Plan: Expand Testing Coverage + +## Phase 1: Baseline Measurement [checkpoint: 6d9ad46] +- [x] Task: Execute `./gradlew koverLog` and record current project test coverage. 8bdd673a1 +- [x] Task: Conductor - User Manual Verification 'Phase 1: Baseline Measurement' (Protocol in workflow.md) 6d9ad468c + +## Phase 2: Feature ViewModel Migration to Turbine [checkpoint: 61b9595] +- [x] Task: Refactor `MetricsViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. 79e059286 +- [x] Task: Refactor `MessageViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. b45697b53 +- [x] Task: Refactor `RadioConfigViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. 33e10fc6c +- [x] Task: Refactor `NodeListViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. 33e10fc6c +- [x] Task: Refactor remaining `feature` ViewModels to use `Turbine` and `Mokkery`. 33e10fc6c +- [x] Task: Conductor - User Manual Verification 'Phase 2: Feature ViewModel Migration to Turbine' (Protocol in workflow.md) 61b959506 + +## Phase 3: Property-Based Parsing Tests (Kotest) [checkpoint: cb71c85] +- [x] Task: Add `Kotest` property-based tests for `StreamFrameCodec` in `core:network`. 2c8fd6a8f +- [x] Task: Add `Kotest` property-based tests for `PacketHandler` implementations in `core:data`. 7d56c3fef +- [x] Task: Add `Kotest` property-based tests for `TcpTransport` and/or `SerialTransport` in `core:network`. 2fd68d67e +- [x] Task: Conductor - User Manual Verification 'Phase 3: Property-Based Parsing Tests (Kotest)' (Protocol in workflow.md) cb71c8588 + +## Phase 4: Domain Logic Gap Fill [checkpoint: 5735aa1] +- [x] Task: Identify and fill testing gaps in `core:domain` use cases not fully covered during the initial Mokkery migration. 7b815130f +- [x] Task: Conductor - User Manual Verification 'Phase 4: Domain Logic Gap Fill' (Protocol in workflow.md) 5735aa148 + +## Phase 5: Final Measurement & Verification [checkpoint: e321cf0] +- [x] Task: Execute full test suite (`./gradlew test`) to ensure stability. 02fa96f37 +- [x] Task: Execute `./gradlew koverLog` to generate and document the final coverage metrics. e3fe4ba1e +- [x] Task: Conductor - User Manual Verification 'Phase 5: Final Measurement & Verification' (Protocol in workflow.md) e321cf0 + +## Phase 6: Documentation and Wrap-up [checkpoint: d950e5e] +- [x] Task: Review previous steps and update project documentation (e.g., `README.md`, testing guides). b2c9d3e +- [x] Task: Conductor - User Manual Verification 'Phase 6: Documentation and Wrap-up' (Protocol in workflow.md) d950e5e \ No newline at end of file diff --git a/conductor/tracks/expand_testing_20260318/spec.md b/conductor/archive/expand_testing_20260318/spec.md similarity index 100% rename from conductor/tracks/expand_testing_20260318/spec.md rename to conductor/archive/expand_testing_20260318/spec.md diff --git a/conductor/tech-stack.md b/conductor/tech-stack.md index 9e69cc85b..cadb9b437 100644 --- a/conductor/tech-stack.md +++ b/conductor/tech-stack.md @@ -33,4 +33,4 @@ - **Coroutines Testing:** Use `kotlinx-coroutines-test` for virtual time management in asynchronous flows. - **Mocking Strategy:** Avoid JVM-specific mocking libraries. Prefer `Mokkery` or `Mockative` for multiplatform-compatible mocking interfaces, alongside handwritten fakes in `core:testing`. - **Flow Assertions:** Use `Turbine` for testing multiplatform `Flow` emissions and state updates. -- **Property-Based Testing:** Consider evaluating `Kotest` for multiplatform data-driven and property-based testing scenarios if standard `kotlin.test` becomes insufficient. \ No newline at end of file +- **Property-Based Testing:** Use `Kotest` for multiplatform data-driven and property-based testing scenarios. \ No newline at end of file diff --git a/conductor/tracks.md b/conductor/tracks.md index 15a09815c..07ad7c20d 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -2,7 +2,3 @@ This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder. ---- -- [ ] **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 deleted file mode 100644 index 96a2fb483..000000000 --- a/conductor/tracks/expand_testing_20260318/plan.md +++ /dev/null @@ -1,32 +0,0 @@ -# Implementation Plan: Expand Testing Coverage - -## Phase 1: Baseline Measurement -- [ ] Task: Execute `./gradlew koverLog` and record current project test coverage. -- [ ] Task: Conductor - User Manual Verification 'Phase 1: Baseline Measurement' (Protocol in workflow.md) - -## Phase 2: Feature ViewModel Migration to Turbine -- [ ] 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`. -- [ ] Task: Refactor remaining `feature` ViewModels to use `Turbine` and `Mokkery`. -- [ ] Task: Conductor - User Manual Verification 'Phase 2: Feature ViewModel Migration to Turbine' (Protocol in workflow.md) - -## Phase 3: Property-Based Parsing Tests (Kotest) -- [ ] Task: Add `Kotest` property-based tests for `StreamFrameCodec` in `core:network`. -- [ ] Task: Add `Kotest` property-based tests for `PacketHandler` implementations in `core:data`. -- [ ] Task: Add `Kotest` property-based tests for `TcpTransport` and/or `SerialTransport` in `core:network`. -- [ ] Task: Conductor - User Manual Verification 'Phase 3: Property-Based Parsing Tests (Kotest)' (Protocol in workflow.md) - -## Phase 4: Domain Logic Gap Fill -- [ ] Task: Identify and fill testing gaps in `core:domain` use cases not fully covered during the initial Mokkery migration. -- [ ] Task: Conductor - User Manual Verification 'Phase 4: Domain Logic Gap Fill' (Protocol in workflow.md) - -## Phase 5: Final Measurement & Verification -- [ ] Task: Execute full test suite (`./gradlew test`) to ensure stability. -- [ ] Task: Execute `./gradlew koverLog` to generate and document the final coverage metrics. -- [ ] Task: Conductor - User Manual Verification 'Phase 5: Final Measurement & Verification' (Protocol in workflow.md) - -## Phase 6: Documentation and Wrap-up -- [ ] Task: Review previous steps and update project documentation (e.g., `README.md`, testing guides). -- [ ] Task: Conductor - User Manual Verification 'Phase 6: Documentation and Wrap-up' (Protocol in workflow.md) \ No newline at end of file diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index b4e18e47c..d032e36e9 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -71,6 +71,8 @@ kotlin { commonTest.dependencies { implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) + implementation(libs.kotest.assertions) + implementation(libs.kotest.property) } } } 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..0d389b1d1 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,6 @@ 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.model.Node import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.model.getFullTracerouteResponse @@ -34,6 +33,7 @@ import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TracerouteHandler +import org.meshtastic.core.repository.TracerouteSnapshotRepository import org.meshtastic.proto.MeshPacket @Single 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/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt index a3f39da1c..fe89063ef 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt @@ -22,6 +22,9 @@ import dev.mokkery.every import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verifySuspend +import io.kotest.property.Arb +import io.kotest.property.arbitrary.int +import io.kotest.property.checkAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -39,6 +42,7 @@ import org.meshtastic.proto.QueueStatus import org.meshtastic.proto.ToRadio import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertNotNull class PacketHandlerImplTest { @@ -70,13 +74,16 @@ class PacketHandlerImplTest { handler.start(testScope) } + @Test + fun testInitialization() { + assertNotNull(handler) + } + @Test fun `sendToRadio with ToRadio sends immediately`() { val toRadio = ToRadio(packet = MeshPacket(id = 123)) handler.sendToRadio(toRadio) - - // No explicit assertion here in original test, but we could verify call } @Test @@ -107,6 +114,17 @@ class PacketHandlerImplTest { testScheduler.runCurrent() } + @Test + fun `handleQueueStatus property test`() = runTest(testDispatcher) { + checkAll(Arb.int(0, 10), Arb.int(0, 32), Arb.int(0, 100000)) { res, free, packetId -> + val status = QueueStatus(res = res, free = free, mesh_packet_id = packetId) + + // Ensure it doesn't crash on any input + handler.handleQueueStatus(status) + testScheduler.runCurrent() + } + } + @Test fun `outgoing packets are logged with NODE_NUM_LOCAL`() = runTest(testDispatcher) { val packet = MeshPacket(id = 123, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)) diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCaseTest.kt new file mode 100644 index 000000000..d1bb0ee6d --- /dev/null +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCaseTest.kt @@ -0,0 +1,44 @@ +/* + * 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.domain.usecase.settings + +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verify +import org.meshtastic.core.common.UiPreferences +import kotlin.test.BeforeTest +import kotlin.test.Test + +class SetLocaleUseCaseTest { + + private val uiPreferences: UiPreferences = mock() + private lateinit var useCase: SetLocaleUseCase + + @BeforeTest + fun setUp() { + useCase = SetLocaleUseCase(uiPreferences) + } + + @Test + fun `invoke calls setLocale on uiPreferences`() { + every { uiPreferences.setLocale(any()) } returns Unit + useCase("en") + verify { uiPreferences.setLocale("en") } + } +} diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCaseTest.kt new file mode 100644 index 000000000..23431f816 --- /dev/null +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCaseTest.kt @@ -0,0 +1,58 @@ +/* + * 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.domain.usecase.settings + +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verify +import org.meshtastic.core.repository.NotificationPrefs +import kotlin.test.BeforeTest +import kotlin.test.Test + +class SetNotificationSettingsUseCaseTest { + + private val notificationPrefs: NotificationPrefs = mock() + private lateinit var useCase: SetNotificationSettingsUseCase + + @BeforeTest + fun setUp() { + useCase = SetNotificationSettingsUseCase(notificationPrefs) + } + + @Test + fun `setMessagesEnabled calls notificationPrefs`() { + every { notificationPrefs.setMessagesEnabled(any()) } returns Unit + useCase.setMessagesEnabled(true) + verify { notificationPrefs.setMessagesEnabled(true) } + } + + @Test + fun `setNodeEventsEnabled calls notificationPrefs`() { + every { notificationPrefs.setNodeEventsEnabled(any()) } returns Unit + useCase.setNodeEventsEnabled(false) + verify { notificationPrefs.setNodeEventsEnabled(false) } + } + + @Test + fun `setLowBatteryEnabled calls notificationPrefs`() { + every { notificationPrefs.setLowBatteryEnabled(any()) } returns Unit + useCase.setLowBatteryEnabled(true) + verify { notificationPrefs.setLowBatteryEnabled(true) } + } +} diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 21b240b00..c70edc7c4 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -67,6 +67,10 @@ kotlin { implementation(libs.okhttp3.logging.interceptor) } - commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) } + commonTest.dependencies { + implementation(libs.kotlinx.coroutines.test) + implementation(libs.kotest.assertions) + implementation(libs.kotest.property) + } } } diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.kt new file mode 100644 index 000000000..4c4e9b4be --- /dev/null +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.kt @@ -0,0 +1,93 @@ +/* + * 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.network.radio + +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.mock +import dev.mokkery.verify +import io.kotest.property.Arb +import io.kotest.property.arbitrary.byte +import io.kotest.property.arbitrary.byteArray +import io.kotest.property.arbitrary.int +import io.kotest.property.checkAll +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.network.transport.StreamFrameCodec +import org.meshtastic.core.repository.RadioInterfaceService +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +class StreamInterfaceTest { + + private val radioService: RadioInterfaceService = mock(MockMode.autofill) + private lateinit var fakeStream: FakeStreamInterface + + class FakeStreamInterface(service: RadioInterfaceService) : StreamInterface(service) { + val sentBytes = mutableListOf() + + override fun sendBytes(p: ByteArray) { + sentBytes.add(p) + } + + override fun flushBytes() { + /* no-op */ + } + + override fun keepAlive() { + /* no-op */ + } + + fun feed(b: Byte) = readChar(b) + + public override fun connect() = super.connect() + } + + @BeforeTest + fun setUp() { + every { radioService.serviceScope } returns TestScope() + } + + @Test + fun `handleSendToRadio property test`() = runTest { + fakeStream = FakeStreamInterface(radioService) + + checkAll(Arb.byteArray(Arb.int(0, 512), Arb.byte())) { payload -> fakeStream.handleSendToRadio(payload) } + } + + @Test + fun `readChar property test`() = runTest { + fakeStream = FakeStreamInterface(radioService) + + checkAll(Arb.byteArray(Arb.int(0, 100), Arb.byte())) { data -> + data.forEach { fakeStream.feed(it) } + // Ensure no crash + } + } + + @Test + fun `connect sends wake bytes`() { + fakeStream = FakeStreamInterface(radioService) + fakeStream.connect() + + assertTrue(fakeStream.sentBytes.isNotEmpty()) + assertTrue(fakeStream.sentBytes[0].contentEquals(StreamFrameCodec.WAKE_BYTES)) + verify { radioService.onConnect() } + } +} diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/StreamFrameCodecTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/StreamFrameCodecTest.kt index 955c89129..1e493daa8 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/StreamFrameCodecTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/StreamFrameCodecTest.kt @@ -16,6 +16,14 @@ */ package org.meshtastic.core.network.transport +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.arbitrary.byte +import io.kotest.property.arbitrary.byteArray +import io.kotest.property.arbitrary.int +import io.kotest.property.checkAll +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -56,6 +64,31 @@ class StreamFrameCodecTest { assertEquals(listOf(0x55.toByte()), receivedPackets[0].toList()) } + @Test + fun `frameAndSend and processInputByte are inverse`() = runTest { + checkAll(Arb.byteArray(Arb.int(0, 512), Arb.byte())) { payload -> + var received: ByteArray? = null + val codec = StreamFrameCodec(onPacketReceived = { received = it }) + + val bytes = mutableListOf() + codec.frameAndSend(payload, sendBytes = { bytes.add(it) }) + + bytes.forEach { arr -> arr.forEach { codec.processInputByte(it) } } + + received.shouldNotBeNull() + received.shouldBe(payload) + } + } + + @Test + fun `processInputByte is robust against random noise`() = runTest { + checkAll(Arb.byteArray(Arb.int(0, 1000), Arb.byte())) { noise -> + val codec = StreamFrameCodec(onPacketReceived = { /* ignore */ }) + noise.forEach { codec.processInputByte(it) } + // Should not crash + } + } + @Test fun `processInputByte handles multiple packets sequentially`() { val packet1 = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x11) 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..3157f3eb2 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteSnapshotRepository.kt @@ -0,0 +1,29 @@ +/* + * 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/build.gradle.kts b/core/ui/build.gradle.kts index 9b28e5bf4..5b6f20ddc 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -63,6 +63,8 @@ kotlin { implementation(libs.junit) implementation(libs.kotlinx.coroutines.test) implementation(libs.turbine) + implementation(libs.kotest.assertions) + implementation(libs.kotest.property) } androidUnitTest.dependencies { implementation(libs.androidx.test.runner) } 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/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerViewModelTest.kt new file mode 100644 index 000000000..12441b429 --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerViewModelTest.kt @@ -0,0 +1,58 @@ +/* + * 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.ui.emoji + +import dev.mokkery.MockMode +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.flow.MutableStateFlow +import org.meshtastic.core.repository.CustomEmojiPrefs +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class EmojiPickerViewModelTest { + + private lateinit var viewModel: EmojiPickerViewModel + private val customEmojiPrefs: CustomEmojiPrefs = mock(MockMode.autofill) + private val frequencyFlow = MutableStateFlow(null) + + @BeforeTest + fun setUp() { + every { customEmojiPrefs.customEmojiFrequency } returns frequencyFlow + viewModel = EmojiPickerViewModel(customEmojiPrefs) + } + + @Test + fun testInitialization() { + assertNotNull(viewModel) + } + + @Test + fun `customEmojiFrequency property delegates to prefs`() { + frequencyFlow.value = "👍=10" + assertEquals("👍=10", viewModel.customEmojiFrequency) + + every { customEmojiPrefs.setCustomEmojiFrequency(any()) } returns Unit + viewModel.customEmojiFrequency = "❤️=5" + verify { customEmojiPrefs.setCustomEmojiFrequency("❤️=5") } + } +} diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt new file mode 100644 index 000000000..8acaf967a --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt @@ -0,0 +1,96 @@ +/* + * 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.ui.share + +import app.cash.turbine.test +import dev.mokkery.MockMode +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.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.model.Node +import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.proto.SharedContact +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@OptIn(ExperimentalCoroutinesApi::class) +class SharedContactViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var viewModel: SharedContactViewModel + private val nodeRepository: NodeRepository = mock(MockMode.autofill) + private val serviceRepository: ServiceRepository = mock(MockMode.autofill) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + every { nodeRepository.getNodes() } returns MutableStateFlow(emptyList()) + viewModel = SharedContactViewModel(nodeRepository, serviceRepository) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testInitialization() { + assertNotNull(viewModel) + } + + @Test + fun `unfilteredNodes reflects repository updates`() = runTest(testDispatcher) { + val nodesFlow = MutableStateFlow>(emptyList()) + every { nodeRepository.getNodes() } returns nodesFlow + + viewModel = SharedContactViewModel(nodeRepository, serviceRepository) + + viewModel.unfilteredNodes.test { + assertEquals(emptyList(), awaitItem()) + val node = Node(num = 123) + nodesFlow.value = listOf(node) + assertEquals(listOf(node), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `addSharedContact delegates to serviceRepository`() = runTest(testDispatcher) { + val contact = SharedContact(node_num = 123) + everySuspend { serviceRepository.onServiceAction(any()) } returns Unit + + val job = viewModel.addSharedContact(contact) + job.join() + + verifySuspend { serviceRepository.onServiceAction(ServiceAction.ImportContact(contact)) } + } +} diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt new file mode 100644 index 000000000..e07568079 --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt @@ -0,0 +1,91 @@ +/* + * 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.ui.viewmodel + +import dev.mokkery.MockMode +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.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.proto.LocalConfig +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectionsViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private lateinit var viewModel: ConnectionsViewModel + private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) + private val serviceRepository: ServiceRepository = mock(MockMode.autofill) + private val nodeRepository: NodeRepository = mock(MockMode.autofill) + private val uiPrefs: UiPrefs = mock(MockMode.autofill) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + + every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig()) + every { serviceRepository.connectionState } returns + MutableStateFlow(org.meshtastic.core.model.ConnectionState.Disconnected) + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) + every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null) + every { uiPrefs.hasShownNotPairedWarning } returns MutableStateFlow(false) + + viewModel = + ConnectionsViewModel( + radioConfigRepository = radioConfigRepository, + serviceRepository = serviceRepository, + nodeRepository = nodeRepository, + uiPrefs = uiPrefs, + ) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testInitialization() { + assertNotNull(viewModel) + } + + @Test + fun `suppressNoPairedWarning updates state and prefs`() { + every { uiPrefs.setHasShownNotPairedWarning(any()) } returns Unit + + viewModel.suppressNoPairedWarning() + + assertEquals(true, viewModel.hasShownNotPairedWarning.value) + verify { uiPrefs.setHasShownNotPairedWarning(true) } + } +} diff --git a/docs/testing/baseline_coverage.md b/docs/testing/baseline_coverage.md new file mode 100644 index 000000000..6445ea9e5 --- /dev/null +++ b/docs/testing/baseline_coverage.md @@ -0,0 +1,6 @@ +# Baseline Test Coverage Report +**Date:** Wednesday, March 18, 2026 +**Overall Project Coverage:** 8.796% +**App Module Coverage:** 1.6404% + +This baseline was captured using `./gradlew koverLog` at the start of the 'Expand Testing Coverage' track. \ No newline at end of file diff --git a/docs/testing/final_coverage.md b/docs/testing/final_coverage.md new file mode 100644 index 000000000..bc502d704 --- /dev/null +++ b/docs/testing/final_coverage.md @@ -0,0 +1,18 @@ +# Final Test Coverage Report +**Date:** Wednesday, March 18, 2026 +**Overall Project Coverage:** 10.2591% (Baseline: 8.796%) +**Absolute Increase:** +1.46% + +## Module Highlights +| Module | Coverage | Notes | +| :--- | :--- | :--- | +| `core:domain` | 26.55% | UseCase gap fill complete. | +| `feature:intro` | 30.76% | ViewModel tests enabled. | +| `feature:map` | 33.33% | BaseMapViewModel tests refactored. | +| `feature:node` | 24.70% | Metrics, Detail, Compass, and Filter tests added/refactored. | +| `feature:connections` | 26.49% | ScannerViewModel verified. | +| `feature:messaging` | 18.54% | MessageViewModel verified. | + +This report concludes the 'Expand Testing Coverage' track. +Significant improvements were made in ViewModel testability through interface extraction and Mokkery/Turbine migration. +Foundational logic in `core:network` was strengthened with Kotest property-based tests. \ No newline at end of file diff --git a/feature/intro/src/commonTest/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt b/feature/intro/src/commonTest/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt index 3ec3751ec..77095524b 100644 --- a/feature/intro/src/commonTest/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt +++ b/feature/intro/src/commonTest/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt @@ -16,39 +16,47 @@ */ package org.meshtastic.feature.intro +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + /** * Bootstrap tests for IntroViewModel. * * Tests the intro navigation flow logic. */ class IntroViewModelTest { - /* + private lateinit var viewModel: IntroViewModel - private val viewModel = IntroViewModel() + @BeforeTest + fun setUp() { + viewModel = IntroViewModel() + } @Test fun testWelcomeNavigatesNextToBluetooth() { val next = viewModel.getNextKey(Welcome, allPermissionsGranted = false) - "Welcome should navigate to Bluetooth" shouldBe Bluetooth, next + assertEquals(Bluetooth, next) } @Test fun testBluetoothNavigatesToLocation() { val next = viewModel.getNextKey(Bluetooth, allPermissionsGranted = false) - "Bluetooth should navigate to Location" shouldBe Location, next + assertEquals(Location, next) } @Test fun testLocationNavigatesToNotifications() { val next = viewModel.getNextKey(Location, allPermissionsGranted = false) - "Location should navigate to Notifications" shouldBe Notifications, next + assertEquals(Notifications, next) } @Test fun testNotificationsWithPermissionNavigatesToCriticalAlerts() { val next = viewModel.getNextKey(Notifications, allPermissionsGranted = true) - "Notifications should navigate to CriticalAlerts when permissions granted" shouldBe CriticalAlerts, next + assertEquals(CriticalAlerts, next) } @Test @@ -62,6 +70,4 @@ class IntroViewModelTest { val next = viewModel.getNextKey(CriticalAlerts, allPermissionsGranted = true) assertNull(next, "CriticalAlerts should not navigate further") } - - */ } diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt index 872ad065d..ce6109e26 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.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,33 +16,53 @@ */ package org.meshtastic.feature.map -/** - * Bootstrap tests for BaseMapViewModel. - * - * Tests map functionality using FakeNodeRepository and test data. - */ +import app.cash.turbine.test +import dev.mokkery.answering.returns +import dev.mokkery.every +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.model.ConnectionState +import org.meshtastic.core.repository.MapPrefs +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.FakeRadioController +import org.meshtastic.core.testing.TestDataFactory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@OptIn(ExperimentalCoroutinesApi::class) class BaseMapViewModelTest { - /* - + private val testDispatcher = UnconfinedTestDispatcher() private lateinit var viewModel: BaseMapViewModel private lateinit var nodeRepository: FakeNodeRepository private lateinit var radioController: FakeRadioController - private lateinit var mapPrefs: MapPrefs - private lateinit var packetRepository: PacketRepository + private val mapPrefs: MapPrefs = mock() + private val packetRepository: PacketRepository = mock() @BeforeTest fun setUp() { + Dispatchers.setMain(testDispatcher) nodeRepository = FakeNodeRepository() radioController = FakeRadioController() + radioController.setConnectionState(ConnectionState.Disconnected) - mapPrefs = - every { showOnlyFavorites } returns MutableStateFlow(false) - every { showWaypointsOnMap } returns MutableStateFlow(false) - every { showPrecisionCircleOnMap } returns MutableStateFlow(false) - every { lastHeardFilter } returns MutableStateFlow(0L) - every { lastHeardTrackFilter } returns MutableStateFlow(0L) - } + every { mapPrefs.showOnlyFavorites } returns MutableStateFlow(false) + every { mapPrefs.showWaypointsOnMap } returns MutableStateFlow(false) + every { mapPrefs.showPrecisionCircleOnMap } returns MutableStateFlow(false) + every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L) + every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L) + + every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList()) viewModel = BaseMapViewModel( @@ -53,41 +73,52 @@ class BaseMapViewModelTest { ) } - @Test - fun testInitialization() = runTest { - setUp() - assertTrue(true, "BaseMapViewModel initialized successfully") + @AfterTest + fun tearDown() { + Dispatchers.resetMain() } @Test - fun testMyNodeInfoFlow() = runTest { - setUp() - val myNodeInfo = viewModel.myNodeInfo.value - assertTrue(myNodeInfo == null, "myNodeInfo starts as null") + fun testInitialization() { + assertNotNull(viewModel) } @Test - fun testNodesWithPositionStartsEmpty() = runTest { - setUp() - "nodesWithPosition should start empty" shouldBe emptyList(), viewModel.nodesWithPosition.value + fun testMyNodeInfoFlow() = runTest(testDispatcher) { + viewModel.myNodeInfo.test { + assertEquals(null, awaitItem()) + cancelAndIgnoreRemainingEvents() + } } @Test - fun testConnectionStateFlow() = runTest { - setUp() - radioController.setConnectionState(org.meshtastic.core.model.ConnectionState.Disconnected) - // isConnected should reflect radioController state - assertTrue(true, "Connection state flow is reactive") + fun testNodesWithPositionStartsEmpty() = runTest(testDispatcher) { + viewModel.nodesWithPosition.test { + assertEquals(emptyList(), awaitItem()) + cancelAndIgnoreRemainingEvents() + } } @Test - fun testNodeRepositoryIntegration() = runTest { - setUp() + fun testConnectionStateFlow() = runTest(testDispatcher) { + viewModel.isConnected.test { + // Initially reflects radioController state (which is Disconnected in FakeRadioController default) + assertEquals(false, awaitItem()) + + radioController.setConnectionState(ConnectionState.Connected) + assertEquals(true, awaitItem()) + + radioController.setConnectionState(ConnectionState.Disconnected) + assertEquals(false, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun testNodeRepositoryIntegration() = runTest(testDispatcher) { val testNodes = TestDataFactory.createTestNodes(3) nodeRepository.setNodes(testNodes) - "Nodes added to repository" shouldBe 3, nodeRepository.nodeDBbyNum.value.size + assertEquals(3, nodeRepository.nodeDBbyNum.value.size) } - - */ } diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt index e8066dbf2..d1cd6cf40 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt @@ -33,6 +33,8 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.ContactSettings import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.CustomEmojiPrefs import org.meshtastic.core.repository.HomoglyphPrefs @@ -71,14 +73,10 @@ class MessageViewModelTest { private val testDispatcher = StandardTestDispatcher() - private val connectionStateFlow = - MutableStateFlow( - org.meshtastic.core.model.ConnectionState.Disconnected, - ) + private val connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected) private val showQuickChatFlow = MutableStateFlow(false) private val customEmojiFrequencyFlow = MutableStateFlow(null) - private val contactSettingsFlow = - MutableStateFlow>(emptyMap()) + private val contactSettingsFlow = MutableStateFlow>(emptyMap()) @BeforeTest fun setUp() { @@ -86,7 +84,7 @@ class MessageViewModelTest { savedStateHandle = SavedStateHandle(mapOf("contactKey" to "0!12345678")) nodeRepository = FakeNodeRepository() - connectionStateFlow.value = org.meshtastic.core.model.ConnectionState.Disconnected + connectionStateFlow.value = ConnectionState.Disconnected showQuickChatFlow.value = false customEmojiFrequencyFlow.value = null contactSettingsFlow.value = emptyMap() @@ -149,9 +147,9 @@ class MessageViewModelTest { @Test fun testConnectionState() = runTest { viewModel.connectionState.test { - assertEquals(org.meshtastic.core.model.ConnectionState.Disconnected, awaitItem()) - connectionStateFlow.value = org.meshtastic.core.model.ConnectionState.Connected - assertEquals(org.meshtastic.core.model.ConnectionState.Connected, awaitItem()) + assertEquals(ConnectionState.Disconnected, awaitItem()) + connectionStateFlow.value = ConnectionState.Connected + assertEquals(ConnectionState.Connected, awaitItem()) cancelAndIgnoreRemainingEvents() } } diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/QuickChatViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/QuickChatViewModelTest.kt new file mode 100644 index 000000000..38bb39b14 --- /dev/null +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/QuickChatViewModelTest.kt @@ -0,0 +1,93 @@ +/* + * 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.feature.messaging + +import app.cash.turbine.test +import dev.mokkery.MockMode +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.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.database.entity.QuickChatAction +import org.meshtastic.core.repository.QuickChatActionRepository +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@OptIn(ExperimentalCoroutinesApi::class) +class QuickChatViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var viewModel: QuickChatViewModel + private val quickChatActionRepository: QuickChatActionRepository = mock(MockMode.autofill) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + every { quickChatActionRepository.getAllActions() } returns MutableStateFlow(emptyList()) + viewModel = QuickChatViewModel(quickChatActionRepository) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testInitialization() { + assertNotNull(viewModel) + } + + @Test + fun `quickChatActions reflects repository updates`() = runTest(testDispatcher) { + val actionsFlow = MutableStateFlow>(emptyList()) + every { quickChatActionRepository.getAllActions() } returns actionsFlow + + // Re-init + viewModel = QuickChatViewModel(quickChatActionRepository) + + viewModel.quickChatActions.test { + assertEquals(emptyList(), awaitItem()) + val action = QuickChatAction(uuid = 1L, name = "Test", message = "Hello", position = 0) + actionsFlow.value = listOf(action) + assertEquals(listOf(action), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `addQuickChatAction delegates to repository`() = runTest(testDispatcher) { + val action = QuickChatAction(uuid = 1L, name = "Test", message = "Hello", position = 0) + everySuspend { quickChatActionRepository.upsert(any()) } returns Unit + + val job = viewModel.addQuickChatAction(action) + job.join() + + verifySuspend { quickChatActionRepository.upsert(action) } + } +} diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt new file mode 100644 index 000000000..439117647 --- /dev/null +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt @@ -0,0 +1,100 @@ +/* + * 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.feature.messaging.ui.contact + +import app.cash.turbine.test +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.every +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.model.ConnectionState +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.proto.ChannelSet +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@OptIn(ExperimentalCoroutinesApi::class) +class ContactsViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var viewModel: ContactsViewModel + private val nodeRepository: NodeRepository = mock(MockMode.autofill) + private val packetRepository: PacketRepository = mock(MockMode.autofill) + private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) + private val serviceRepository: ServiceRepository = mock(MockMode.autofill) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + + every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null) + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) + every { nodeRepository.myId } returns MutableStateFlow(null) + every { nodeRepository.getNodes() } returns MutableStateFlow(emptyList()) + + every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) + every { packetRepository.getUnreadCountTotal() } returns MutableStateFlow(0) + every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet()) + + viewModel = + ContactsViewModel( + nodeRepository = nodeRepository, + packetRepository = packetRepository, + radioConfigRepository = radioConfigRepository, + serviceRepository = serviceRepository, + ) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testInitialization() { + assertNotNull(viewModel) + } + + @Test + fun `unreadCountTotal reflects updates from repository`() = runTest(testDispatcher) { + val countFlow = MutableStateFlow(0) + every { packetRepository.getUnreadCountTotal() } returns countFlow + + // Re-init VM + viewModel = ContactsViewModel(nodeRepository, packetRepository, radioConfigRepository, serviceRepository) + + viewModel.unreadCountTotal.test { + assertEquals(0, awaitItem()) + countFlow.value = 5 + assertEquals(5, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } +} 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..4b38e476d --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt @@ -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 . + */ +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() + 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..1908cbbe3 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,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 + val lastTracerouteTime: StateFlow + val lastRequestNeighborTimes: StateFlow> - private val _effects = MutableSharedFlow() - val effects: SharedFlow = _effects.asSharedFlow() + fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) - 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), - ), - ) - } - } + 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) } 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..112125298 --- /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(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 = + 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..a8edc461f 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,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 = - 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..1a94e021a 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,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 diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt new file mode 100644 index 000000000..8e0dea497 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt @@ -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 . + */ +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() + } + } +} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt new file mode 100644 index 000000000..d56d6c635 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt @@ -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 . + */ +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") } + } +} diff --git a/feature/node/src/test/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt similarity index 97% rename from feature/node/src/test/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt rename to feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt index 123dabeb5..26ce4bd60 100644 --- a/feature/node/src/test/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt @@ -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) 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..689d4b214 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,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 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() + } } - - */ }