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