From 9f3fe865e37f0e210c1d21da10cf035423dd9c67 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:35:41 -0500 Subject: [PATCH] test: migrate MigrationTest to runTest and add missing repository fakes (#5171) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pr5167.diff | 295 ++++++++++++++++++ .../core/database/dao/MigrationTest.kt | 12 +- .../testing/FakeDeviceHardwareRepository.kt | 69 ++++ .../testing/FakeFirmwareReleaseRepository.kt | 57 ++++ .../testing/FakeQuickChatActionRepository.kt | 71 +++++ .../core/testing/FakeRadioConfigRepository.kt | 162 ++++++++++ .../FakeTracerouteSnapshotRepository.kt | 55 ++++ .../core/testing/RepositoryFakesTest.kt | 129 ++++++++ 8 files changed, 844 insertions(+), 6 deletions(-) create mode 100644 .pr5167.diff create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt create mode 100644 core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt diff --git a/.pr5167.diff b/.pr5167.diff new file mode 100644 index 000000000..d0a809449 --- /dev/null +++ b/.pr5167.diff @@ -0,0 +1,295 @@ +diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt +new file mode 100644 +index 0000000000..2a27b96906 +--- /dev/null ++++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt +@@ -0,0 +1,39 @@ ++/* ++ * 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.common.di ++ ++import kotlinx.coroutines.CoroutineScope ++import kotlinx.coroutines.SupervisorJob ++import org.koin.core.annotation.Single ++import org.meshtastic.core.common.util.ioDispatcher ++ ++/** ++ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components. ++ * ++ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled ++ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not ++ * cancel siblings, and by [ioDispatcher] so work runs off the main thread. ++ * ++ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch ++ * and should be used sparingly. ++ */ ++interface ApplicationCoroutineScope : CoroutineScope ++ ++@Single(binds = [ApplicationCoroutineScope::class]) ++internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope { ++ override val coroutineContext = SupervisorJob() + ioDispatcher ++} +diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +index 231c84d401..5365ab95e2 100644 +--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt ++++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +@@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect + import co.touchlab.kermit.Logger + import com.eygraber.uri.toAndroidUri + import com.eygraber.uri.toKmpUri +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.withContext + import org.jetbrains.compose.resources.StringResource + import org.jetbrains.compose.resources.getString + import org.meshtastic.core.common.gpsDisabled + import org.meshtastic.core.common.util.CommonUri ++import org.meshtastic.core.common.util.ioDispatcher + import java.net.URLEncoder + + @Composable +@@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> + val context = LocalContext.current + return remember(context) { + { uri, maxChars -> +- withContext(Dispatchers.IO) { ++ withContext(ioDispatcher) { + @Suppress("TooGenericExceptionCaught") + try { + val androidUri = uri.toAndroidUri() +diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +index 031e1fe35d..a938f92ea6 100644 +--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt ++++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +@@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util + + import androidx.compose.runtime.Composable + import co.touchlab.kermit.Logger +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.withContext + import org.jetbrains.compose.resources.StringResource + import org.meshtastic.core.common.util.CommonUri ++import org.meshtastic.core.common.util.ioDispatcher + import java.awt.Desktop + import java.awt.FileDialog + import java.awt.Frame +@@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT + /** JVM — Reads text from a file URI. */ + @Composable + actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars -> +- withContext(Dispatchers.IO) { ++ withContext(ioDispatcher) { + @Suppress("TooGenericExceptionCaught") + try { + val file = File(URI(uri.toString())) +diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +index dc1c459716..f8ff9fcac8 100644 +--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt ++++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch + import kotlinx.coroutines.withTimeoutOrNull + import org.jetbrains.compose.resources.StringResource + import org.koin.core.annotation.KoinViewModel ++import org.meshtastic.core.common.di.ApplicationCoroutineScope + import org.meshtastic.core.common.util.CommonUri + import org.meshtastic.core.common.util.safeCatching + import org.meshtastic.core.database.entity.FirmwareRelease +@@ -91,6 +92,7 @@ class FirmwareUpdateViewModel( + private val firmwareUpdateManager: FirmwareUpdateManager, + private val usbManager: FirmwareUsbManager, + private val fileHandler: FirmwareFileHandler, ++ private val applicationScope: ApplicationCoroutineScope, + ) : ViewModel() { + + private val _state = MutableStateFlow(FirmwareUpdateState.Idle) +@@ -124,12 +126,10 @@ class FirmwareUpdateViewModel( + + override fun onCleared() { + super.onCleared() +- // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a +- // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a +- // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope +- // is cancelled concurrently. +- @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) +- kotlinx.coroutines.GlobalScope.launch(NonCancellable) { ++ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the ++ // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup ++ // running even if something tries to cancel it mid-flight. ++ applicationScope.launch(NonCancellable) { + tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) + } + } +diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +index 4c48a1ced5..030d84effd 100644 +--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt ++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +@@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest { + firmwareUpdateManager, + usbManager, + fileHandler, ++ TestApplicationCoroutineScope(testDispatcher), + ) + + @Test +diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +index 7032ed4088..a8eddff838 100644 +--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt ++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +@@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest { + firmwareUpdateManager, + usbManager, + fileHandler, ++ TestApplicationCoroutineScope(testDispatcher), + ) + + @Test +diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt +new file mode 100644 +index 0000000000..3ef5c44ef4 +--- /dev/null ++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt +@@ -0,0 +1,26 @@ ++/* ++ * 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.firmware ++ ++import kotlinx.coroutines.CoroutineDispatcher ++import kotlinx.coroutines.CoroutineScope ++import kotlinx.coroutines.SupervisorJob ++import org.meshtastic.core.common.di.ApplicationCoroutineScope ++ ++internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) : ++ ApplicationCoroutineScope, ++ CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher) +diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +index acb1545bdd..23a0d03ab2 100644 +--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt ++++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +@@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest { + firmwareUpdateManager, + usbManager, + fileHandler, ++ TestApplicationCoroutineScope(testDispatcher), + ) + + // ----------------------------------------------------------------------- +diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +index c251b4d5ef..315ad1da85 100644 +--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt ++++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +@@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger + import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.launch + import kotlinx.coroutines.withContext ++import org.meshtastic.core.common.util.ioDispatcher + import org.meshtastic.core.resources.Res + import org.meshtastic.core.resources.debug_export_failed + import org.meshtastic.core.resources.debug_export_success +@@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List) = +- withContext(Dispatchers.IO) { ++ withContext(ioDispatcher) { + try { + if (logs.isEmpty()) { + withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") } +diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +index 9afde85e5f..a28a576788 100644 +--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt ++++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable + import androidx.compose.runtime.rememberCoroutineScope + import androidx.compose.ui.platform.LocalContext + import co.touchlab.kermit.Logger +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.launch + import kotlinx.coroutines.withContext ++import org.meshtastic.core.common.util.ioDispatcher + + @Composable + actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit { +@@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr + return { fileName -> exportLauncher.launch(fileName) } + } + +-private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) { ++private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) { + try { + context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) } + Logger.i { "TAK data package exported successfully to $targetUri" } +diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +index 5b63cc90a3..a9a7285593 100644 +--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt ++++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging + import androidx.compose.runtime.Composable + import androidx.compose.runtime.rememberCoroutineScope + import co.touchlab.kermit.Logger +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.launch + import kotlinx.coroutines.withContext ++import org.meshtastic.core.common.util.ioDispatcher + import java.awt.FileDialog + import java.awt.Frame + import java.io.File +@@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List ByteAr + if (directory != null && file != null) { + val targetFile = File(directory, file) + val data = dataPackageProvider() +- withContext(Dispatchers.IO) { targetFile.writeBytes(data) } ++ withContext(ioDispatcher) { targetFile.writeBytes(data) } + Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" } + } + } diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt index 8062afa76..451a62174 100644 --- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt +++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt @@ -20,7 +20,7 @@ import androidx.room3.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString import org.junit.After import org.junit.Before @@ -59,7 +59,7 @@ class MigrationTest { ) @Before - fun createDb(): Unit = runBlocking { + fun createDb(): Unit = runTest { val context = ApplicationProvider.getApplicationContext() database = Room.inMemoryDatabaseBuilder( @@ -77,7 +77,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_duplicatePSK() = runBlocking { + fun testMigrateChannelsByPSK_duplicatePSK() = runTest { // PSK \"AQ==\" is base64 for single byte 0x01 val pskBytes = byteArrayOf(0x01).toByteString() @@ -103,7 +103,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_reorder() = runBlocking { + fun testMigrateChannelsByPSK_reorder() = runTest { val pskA = byteArrayOf(0x01).toByteString() val pskB = byteArrayOf(0x02).toByteString() @@ -122,7 +122,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_disambiguateByName() = runBlocking { + fun testMigrateChannelsByPSK_disambiguateByName() = runTest { val pskA = byteArrayOf(0x01).toByteString() insertPacket(channel = 0, text = "Msg A1") @@ -141,7 +141,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runBlocking { + fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runTest { val pskA = byteArrayOf(0x01).toByteString() insertPacket(channel = 0, text = "Msg A") diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt new file mode 100644 index 000000000..ef8cac0ba --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt @@ -0,0 +1,69 @@ +/* + * 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.testing + +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.repository.DeviceHardwareRepository + +/** + * A test double for [DeviceHardwareRepository] backed by an in-memory map keyed by `(hwModel, target)`. + * + * Call [setHardware] (or [setHardwareForModel]) to seed results, or [setResult] to control the exact [Result] returned + * for a given lookup. By default, lookups return `Result.success(null)`. + */ +class FakeDeviceHardwareRepository : + BaseFake(), + DeviceHardwareRepository { + + private val hardware = mutableMapOf, Result>() + private val calls = mutableListOf>() + + init { + registerResetAction { + hardware.clear() + calls.clear() + } + } + + /** Records every [getDeviceHardwareByModel] invocation for assertion. */ + val recordedCalls: List> + get() = calls.toList() + + override suspend fun getDeviceHardwareByModel( + hwModel: Int, + target: String?, + forceRefresh: Boolean, + ): Result { + calls.add(Triple(hwModel, target, forceRefresh)) + return hardware[hwModel to target] ?: hardware[hwModel to null] ?: Result.success(null) + } + + /** Seeds a successful lookup for the given model/target pair. */ + fun setHardware(hwModel: Int, target: String? = null, device: DeviceHardware?) { + hardware[hwModel to target] = Result.success(device) + } + + /** Seeds a successful lookup for any target of the given model. */ + fun setHardwareForModel(hwModel: Int, device: DeviceHardware?) { + hardware[hwModel to null] = Result.success(device) + } + + /** Seeds an arbitrary [Result] for the given lookup (use to test failure paths). */ + fun setResult(hwModel: Int, target: String? = null, result: Result) { + hardware[hwModel to target] = result + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt new file mode 100644 index 000000000..166256764 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt @@ -0,0 +1,57 @@ +/* + * 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.testing + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.repository.FirmwareReleaseRepository + +/** + * A test double for [FirmwareReleaseRepository] that exposes stable and alpha releases as + * [kotlinx.coroutines.flow.MutableStateFlow]s. + * + * Use [setStableRelease] and [setAlphaRelease] to drive the emitted values. + */ +class FakeFirmwareReleaseRepository : + BaseFake(), + FirmwareReleaseRepository { + + private val _stableRelease = mutableStateFlow(null) + private val _alphaRelease = mutableStateFlow(null) + + override val stableRelease: Flow = _stableRelease + override val alphaRelease: Flow = _alphaRelease + + var invalidateCacheCalls: Int = 0 + private set + + init { + registerResetAction { invalidateCacheCalls = 0 } + } + + override suspend fun invalidateCache() { + invalidateCacheCalls++ + } + + fun setStableRelease(release: FirmwareRelease?) { + _stableRelease.value = release + } + + fun setAlphaRelease(release: FirmwareRelease?) { + _alphaRelease.value = release + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt new file mode 100644 index 000000000..215542485 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt @@ -0,0 +1,71 @@ +/* + * 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.testing + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.database.entity.QuickChatAction +import org.meshtastic.core.repository.QuickChatActionRepository + +/** + * A test double for [QuickChatActionRepository] that keeps actions in an in-memory list (sorted by `position`). + * + * The in-memory list is exposed reactively through [getAllActions]. + */ +class FakeQuickChatActionRepository : + BaseFake(), + QuickChatActionRepository { + + private val actionsFlow = mutableStateFlow>(emptyList()) + + override fun getAllActions(): Flow> = actionsFlow + + override suspend fun upsert(action: QuickChatAction) { + val existingIndex = actionsFlow.value.indexOfFirst { it.uuid == action.uuid } + actionsFlow.value = + if (existingIndex >= 0) { + actionsFlow.value.toMutableList().also { it[existingIndex] = action } + } else { + actionsFlow.value + action + } + .sortedBy { it.position } + } + + override suspend fun deleteAll() { + actionsFlow.value = emptyList() + } + + override suspend fun delete(action: QuickChatAction) { + actionsFlow.value = + actionsFlow.value + .filterNot { it.uuid == action.uuid } + .map { if (it.position > action.position) it.copy(position = it.position - 1) else it } + } + + override suspend fun setItemPosition(uuid: Long, newPos: Int) { + actionsFlow.value = + actionsFlow.value.map { if (it.uuid == uuid) it.copy(position = newPos) else it }.sortedBy { it.position } + } + + /** Seeds the current list of actions (useful for test setup). */ + fun setActions(actions: List) { + actionsFlow.value = actions.sortedBy { it.position } + } + + /** Returns the current in-memory snapshot. */ + val currentActions: List + get() = actionsFlow.value +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt new file mode 100644 index 000000000..aa68e9b21 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt @@ -0,0 +1,162 @@ +/* + * 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.testing + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceProfile +import org.meshtastic.proto.DeviceUIConfig +import org.meshtastic.proto.FileInfo +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.ModuleConfig + +/** + * A test double for [RadioConfigRepository] backed by in-memory [kotlinx.coroutines.flow.MutableStateFlow]s. + * + * All mutator methods update the underlying state flows synchronously so tests can observe changes immediately. + * [deviceProfileFlow] is derived from [localConfigFlow], [moduleConfigFlow], and the current channel set. + */ +@Suppress("TooManyFunctions") +class FakeRadioConfigRepository : + BaseFake(), + RadioConfigRepository { + + private val channelSetBacking = mutableStateFlow(ChannelSet()) + override val channelSetFlow: Flow = channelSetBacking + + private val localConfigBacking = mutableStateFlow(LocalConfig()) + override val localConfigFlow: Flow = localConfigBacking + + private val moduleConfigBacking = mutableStateFlow(LocalModuleConfig()) + override val moduleConfigFlow: Flow = moduleConfigBacking + + private val deviceProfileBacking = mutableStateFlow(DeviceProfile()) + override val deviceProfileFlow: Flow = deviceProfileBacking + val currentDeviceProfile: DeviceProfile + get() = deviceProfileBacking.value + + private val deviceUIConfigBacking = mutableStateFlow(null) + override val deviceUIConfigFlow: Flow = deviceUIConfigBacking + + private val fileManifestBacking = mutableStateFlow>(emptyList()) + override val fileManifestFlow: Flow> = fileManifestBacking + + val currentChannelSet: ChannelSet + get() = channelSetBacking.value + + val currentLocalConfig: LocalConfig + get() = localConfigBacking.value + + val currentModuleConfig: LocalModuleConfig + get() = moduleConfigBacking.value + + val currentDeviceUIConfig: DeviceUIConfig? + get() = deviceUIConfigBacking.value + + val currentFileManifest: List + get() = fileManifestBacking.value + + /** + * Last [Config] passed to [setLocalConfig] (null until called). Tests should use [setLocalConfigDirect] to drive + * state. + */ + var lastSetLocalConfig: Config? = null + private set + + /** Last [ModuleConfig] passed to [setLocalModuleConfig] (null until called). */ + var lastSetModuleConfig: ModuleConfig? = null + private set + + init { + registerResetAction { + lastSetLocalConfig = null + lastSetModuleConfig = null + } + } + + override suspend fun clearChannelSet() { + channelSetBacking.value = ChannelSet() + } + + override suspend fun replaceAllSettings(settingsList: List) { + channelSetBacking.value = channelSetBacking.value.copy(settings = settingsList) + } + + override suspend fun updateChannelSettings(channel: Channel) { + val current = channelSetBacking.value.settings.toMutableList() + while (current.size <= channel.index) current.add(ChannelSettings()) + current[channel.index] = channel.settings ?: ChannelSettings() + channelSetBacking.value = channelSetBacking.value.copy(settings = current) + } + + override suspend fun clearLocalConfig() { + localConfigBacking.value = LocalConfig() + } + + override suspend fun setLocalConfig(config: Config) { + lastSetLocalConfig = config + } + + override suspend fun clearLocalModuleConfig() { + moduleConfigBacking.value = LocalModuleConfig() + } + + override suspend fun setLocalModuleConfig(config: ModuleConfig) { + lastSetModuleConfig = config + } + + override suspend fun setDeviceUIConfig(config: DeviceUIConfig) { + deviceUIConfigBacking.value = config + } + + override suspend fun clearDeviceUIConfig() { + deviceUIConfigBacking.value = null + } + + override suspend fun addFileInfo(info: FileInfo) { + fileManifestBacking.value = fileManifestBacking.value + info + } + + override suspend fun clearFileManifest() { + fileManifestBacking.value = emptyList() + } + + /** Directly sets the [LocalConfig] without merging (preferred for test setup). */ + fun setLocalConfigDirect(config: LocalConfig) { + localConfigBacking.value = config + } + + /** Directly sets the [LocalModuleConfig] without merging (preferred for test setup). */ + fun setLocalModuleConfigDirect(config: LocalModuleConfig) { + moduleConfigBacking.value = config + } + + /** Directly sets the combined [DeviceProfile] emitted by [deviceProfileFlow]. */ + fun setDeviceProfile(profile: DeviceProfile) { + deviceProfileBacking.value = profile + } + + /** Directly sets the [ChannelSet] (bypasses [updateChannelSettings]/[replaceAllSettings]). */ + fun setChannelSet(channelSet: ChannelSet) { + channelSetBacking.value = channelSet + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt new file mode 100644 index 000000000..a52b86bd0 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt @@ -0,0 +1,55 @@ +/* + * 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.testing + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.meshtastic.core.repository.TracerouteSnapshotRepository +import org.meshtastic.proto.Position + +/** + * A test double for [TracerouteSnapshotRepository] keyed by `logUuid`. + * + * Use [upsertSnapshotPositions] as you would in production, or [seedSnapshot] to directly inject state for a log. + */ +class FakeTracerouteSnapshotRepository : + BaseFake(), + TracerouteSnapshotRepository { + + private val snapshots = mutableStateFlow>>(emptyMap()) + private val requestIds = mutableMapOf() + + init { + registerResetAction { requestIds.clear() } + } + + override fun getSnapshotPositions(logUuid: String): Flow> = + snapshots.map { it[logUuid].orEmpty() } + + override suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map) { + requestIds[logUuid] = requestId + snapshots.value = snapshots.value.toMutableMap().also { it[logUuid] = positions } + } + + /** Directly seeds the snapshot for a log (bypasses request-id tracking). */ + fun seedSnapshot(logUuid: String, positions: Map) { + snapshots.value = snapshots.value.toMutableMap().also { it[logUuid] = positions } + } + + /** Returns the last request-id recorded for [logUuid], or `null` if none. */ + fun lastRequestId(logUuid: String): Int? = requestIds[logUuid] +} diff --git a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt new file mode 100644 index 000000000..f9a63c712 --- /dev/null +++ b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt @@ -0,0 +1,129 @@ +/* + * 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.testing + +import app.cash.turbine.test +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.database.entity.QuickChatAction +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Position +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RepositoryFakesTest { + + @Test + fun `FakeDeviceHardwareRepository returns seeded hardware and records calls`() = runTest { + val repo = FakeDeviceHardwareRepository() + val hw = DeviceHardware(hwModel = 42, hwModelSlug = "TEST", platformioTarget = "tlora") + repo.setHardware(hwModel = 42, target = "tlora", device = hw) + + val hit = repo.getDeviceHardwareByModel(hwModel = 42, target = "tlora", forceRefresh = false) + val miss = repo.getDeviceHardwareByModel(hwModel = 99) + + assertEquals(hw, hit.getOrNull()) + assertNull(miss.getOrNull()) + assertEquals(2, repo.recordedCalls.size) + assertEquals(Triple(42, "tlora", false), repo.recordedCalls.first()) + } + + @Test + fun `FakeFirmwareReleaseRepository emits stable and alpha releases`() = runTest { + val repo = FakeFirmwareReleaseRepository() + val stable = FirmwareRelease(id = "1.0", title = "1.0", pageUrl = "", zipUrl = "") + val alpha = FirmwareRelease(id = "1.1-a", title = "1.1-a", pageUrl = "", zipUrl = "") + + repo.setStableRelease(stable) + repo.setAlphaRelease(alpha) + + assertEquals(stable, repo.stableRelease.first()) + assertEquals(alpha, repo.alphaRelease.first()) + + repo.invalidateCache() + repo.invalidateCache() + assertEquals(2, repo.invalidateCacheCalls) + } + + @Test + fun `FakeQuickChatActionRepository upsert delete and reorder`() = runTest { + val repo = FakeQuickChatActionRepository() + val a = QuickChatAction(uuid = 1L, name = "A", message = "hi", position = 0) + val b = QuickChatAction(uuid = 2L, name = "B", message = "bye", position = 1) + + repo.upsert(a) + repo.upsert(b) + assertEquals(listOf(a, b), repo.getAllActions().first()) + + repo.setItemPosition(uuid = 1L, newPos = 5) + assertEquals(listOf(2L, 1L), repo.getAllActions().first().map { it.uuid }) + + repo.delete(b) + assertEquals(1, repo.currentActions.size) + + repo.deleteAll() + assertTrue(repo.currentActions.isEmpty()) + } + + @Test + fun `FakeQuickChatActionRepository delete compacts positions`() = runTest { + val repo = FakeQuickChatActionRepository() + val a = QuickChatAction(uuid = 1L, name = "A", message = "", position = 0) + val b = QuickChatAction(uuid = 2L, name = "B", message = "", position = 1) + val c = QuickChatAction(uuid = 3L, name = "C", message = "", position = 2) + repo.upsert(a) + repo.upsert(b) + repo.upsert(c) + + repo.delete(b) + + // Matches real DAO's decrementPositionsAfter: positions must stay contiguous. + assertEquals(listOf(1L to 0, 3L to 1), repo.currentActions.map { it.uuid to it.position }) + } + + @Test + fun `FakeTracerouteSnapshotRepository roundtrips positions keyed by log uuid`() = runTest { + val repo = FakeTracerouteSnapshotRepository() + val positions = mapOf(1 to Position(latitude_i = 10), 2 to Position(latitude_i = 20)) + repo.upsertSnapshotPositions(logUuid = "log-1", requestId = 99, positions = positions) + + repo.getSnapshotPositions("log-1").test { assertEquals(positions, awaitItem()) } + assertEquals(99, repo.lastRequestId("log-1")) + assertNull(repo.lastRequestId("other")) + } + + @Test + fun `FakeRadioConfigRepository tracks channel set and module config`() = runTest { + val repo = FakeRadioConfigRepository() + val a = ChannelSettings(name = "A") + val b = ChannelSettings(name = "B") + + repo.replaceAllSettings(listOf(a, b)) + assertEquals(listOf(a, b), repo.currentChannelSet.settings) + + repo.updateChannelSettings(Channel(index = 1, settings = ChannelSettings(name = "B2"))) + assertEquals("B2", repo.currentChannelSet.settings[1].name) + + repo.clearChannelSet() + assertTrue(repo.currentChannelSet.settings.isEmpty()) + } +}