test: migrate MigrationTest to runTest and add missing repository fakes (#5171)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-04-17 11:35:41 -05:00 committed by GitHub
parent 2a6e27de09
commit 1cd05d5d78
8 changed files with 844 additions and 6 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Pair<Int, String?>, Result<DeviceHardware?>>()
private val calls = mutableListOf<Triple<Int, String?, Boolean>>()
init {
registerResetAction {
hardware.clear()
calls.clear()
}
}
/** Records every [getDeviceHardwareByModel] invocation for assertion. */
val recordedCalls: List<Triple<Int, String?, Boolean>>
get() = calls.toList()
override suspend fun getDeviceHardwareByModel(
hwModel: Int,
target: String?,
forceRefresh: Boolean,
): Result<DeviceHardware?> {
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<DeviceHardware?>) {
hardware[hwModel to target] = result
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<FirmwareRelease?>(null)
private val _alphaRelease = mutableStateFlow<FirmwareRelease?>(null)
override val stableRelease: Flow<FirmwareRelease?> = _stableRelease
override val alphaRelease: Flow<FirmwareRelease?> = _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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<List<QuickChatAction>>(emptyList())
override fun getAllActions(): Flow<List<QuickChatAction>> = 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<QuickChatAction>) {
actionsFlow.value = actions.sortedBy { it.position }
}
/** Returns the current in-memory snapshot. */
val currentActions: List<QuickChatAction>
get() = actionsFlow.value
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<ChannelSet> = channelSetBacking
private val localConfigBacking = mutableStateFlow(LocalConfig())
override val localConfigFlow: Flow<LocalConfig> = localConfigBacking
private val moduleConfigBacking = mutableStateFlow(LocalModuleConfig())
override val moduleConfigFlow: Flow<LocalModuleConfig> = moduleConfigBacking
private val deviceProfileBacking = mutableStateFlow(DeviceProfile())
override val deviceProfileFlow: Flow<DeviceProfile> = deviceProfileBacking
val currentDeviceProfile: DeviceProfile
get() = deviceProfileBacking.value
private val deviceUIConfigBacking = mutableStateFlow<DeviceUIConfig?>(null)
override val deviceUIConfigFlow: Flow<DeviceUIConfig?> = deviceUIConfigBacking
private val fileManifestBacking = mutableStateFlow<List<FileInfo>>(emptyList())
override val fileManifestFlow: Flow<List<FileInfo>> = 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<FileInfo>
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<ChannelSettings>) {
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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Map<String, Map<Int, Position>>>(emptyMap())
private val requestIds = mutableMapOf<String, Int>()
init {
registerResetAction { requestIds.clear() }
}
override fun getSnapshotPositions(logUuid: String): Flow<Map<Int, Position>> =
snapshots.map { it[logUuid].orEmpty() }
override suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map<Int, Position>) {
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<Int, Position>) {
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]
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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())
}
}