fix: audit leftovers (neighbor-info interval unit, shared constants) (#7481)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-30 04:44:33 +00:00
1 parent 017de61ad1
commit 364fd9d9dd
18 files changed
+212 -58

No files matched your search

+1 -1
View File
@@ -139,7 +139,7 @@ The tiers are named here and the workflows carry the label versions.
- `org.gradle.isolated-projects=true` for better parallelism
### CI Conventions
- **KMP Smoke Compile:** `./gradlew kmpSmokeCompile` is a lifecycle task (registered in `RootConventionPlugin`) that depends on `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` for every KMP module in the hand-maintained `ALL_MODULES_FULL` list, plus `compileAndroidDeviceTest` for `:core:database` and `:core:model`. `scripts/check-module-list.py` fails the PR when that list drifts from `settings.gradle.kts`. CI runs it in `shard-core`.
- **KMP Smoke Compile:** `./gradlew kmpSmokeCompile` is a lifecycle task (registered in `RootConventionPlugin`) that depends on `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` for every KMP module in the hand-maintained `ALL_MODULES_FULL` list, plus `assembleAndroidDeviceTest` for `:core:database` and `:core:model`, so a device-test APK that fails to dex or package fails here. `scripts/check-module-list.py` fails the PR when that list drifts from `settings.gradle.kts`. CI runs it in `shard-core`.
- **Kotlin warnings fail the test shards:** they pass `-PwarningsAsErrors=true`, which sets `allWarningsAsErrors` on every Kotlin compilation (`KotlinAndroid.kt`, plus `desktopApp` and `schema-strings`). The shards don't run the `compile*MainKotlinMetadata` tasks, so a warning only those report doesn't fail CI (today they warn about duplicate KLIB names). Reproduce locally with the same flag on the compile or test tasks you touched.
- **`maxParallelForks` CI logic:** `ProjectExtensions.kt` reads the `ci` Gradle property (`providers.gradleProperty("ci")`) and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass `-Pci=true`.
- **Detekt report formats:** Detekt.kt checks `project.findProperty("ci") == "true"` and disables html, txt, md reports in CI; only xml + sarif are retained for GitHub annotations.
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowSeconds
import java.io.IOException
import java.util.zip.GZIPInputStream
import kotlin.uuid.Uuid
@@ -121,7 +122,7 @@ internal class OfflineRegionExtractor(private val store: OfflineRegionStore) {
maxZoom = zoomRange.last,
tileCount = tiles.size.toLong(),
byteSize = archiveFile.length(),
createdAtEpochSeconds = System.currentTimeMillis() / MILLIS_PER_SECOND,
createdAtEpochSeconds = nowSeconds,
)
.also { store.add(it) }
} catch (e: IOException) {
@@ -171,7 +172,6 @@ internal class OfflineRegionExtractor(private val store: OfflineRegionStore) {
const val MAX_REGIONS = 10
const val MAX_TOTAL_BYTES = 300L * 1024 * 1024
private const val PROGRESS_STRIDE = 10
private const val MILLIS_PER_SECOND = 1_000L
/** Both the Protomaps build and the MVT layers it packages (OpenStreetMap) require attribution. */
const val ATTRIBUTION = "© OpenStreetMap contributors, © Protomaps"
@@ -68,13 +68,13 @@ private fun Project.registerKmpSmokeCompileTask() {
dependsOn("$path:compileKotlinIosSimulatorArm64")
}
// Compile androidDeviceTest sources so instrumented test breakages are caught early.
// These tests require a device/emulator to *run*, but compilation alone is cheap.
DEVICE_TEST_MODULES.forEach { path -> dependsOn("$path:compileAndroidDeviceTest") }
// Assemble, not just compile, the androidDeviceTest APKs: dexing and packaging failures only show up there.
// Running them still needs a device.
DEVICE_TEST_MODULES.forEach { path -> dependsOn("$path:assembleAndroidDeviceTest") }
}
}
/** KMP modules that declare `withDeviceTest {}` and therefore have `compileAndroidDeviceTest` tasks. */
/** KMP modules that declare `withDeviceTest {}` and therefore have `assembleAndroidDeviceTest` tasks. */
private val DEVICE_TEST_MODULES = listOf(":core:database", ":core:model")
/**
@@ -154,7 +154,7 @@ class KableBleConnection(private val scope: CoroutineScope, private val loggingC
// _deviceFlow.emit() is intentionally outside this block — making it
// non-cancellable could hang teardown on a slow collector.
withContext(NonCancellable) {
cleanUpPeripheral(tag = device.address.anonymize())
cleanUpPeripheral(device)
peripheral = p
ActiveBleConnection.active = ActiveConnection(p, device.address)
}
@@ -312,8 +312,8 @@ class KableBleConnection(private val scope: CoroutineScope, private val loggingC
override fun invalidateServiceCache(): Boolean = peripheral?.refreshGattCache() == true
/** Ensures the previous peripheral's GATT resources are fully released. */
private suspend fun cleanUpPeripheral(tag: String) {
withContext(NonCancellable) { safeClosePeripheral(tag) }
private suspend fun cleanUpPeripheral(device: BleDevice) {
withContext(NonCancellable) { safeClosePeripheral(device.address.anonymize()) }
}
/**
@@ -66,7 +66,6 @@ import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
import kotlin.math.absoluteValue
import kotlin.random.Random
import kotlin.time.Duration.Companion.hours
import org.meshtastic.proto.Position as ProtoPosition
@Suppress("TooManyFunctions", "CyclomaticComplexMethod", "LongParameterList")
@@ -435,7 +434,7 @@ class CommandSenderImpl(
val neighborInfoToSend =
neighborInfoHandler.lastNeighborInfo
?: run {
val oneHour = 1.hours.inWholeMinutes.toInt()
val oneHour = TimeConstants.SECONDS_PER_HOUR
Logger.d { "No stored neighbor info from connected radio, sending dummy data" }
NeighborInfo.Builder()
.also { wb ->
@@ -650,8 +650,9 @@ class NodeManagerImpl(
updateNodeAndSchedulePersistence(nodeNum, channel, session, transform)
}
override suspend fun updateNodeAndPersist(nodeNum: Int, channel: Int, transform: (Node) -> Node) {
val result = updateNodeState(nodeNum, channel, transform)?.next ?: return
/** [transform] may run more than once under compare-and-set contention, so it must be side-effect free. */
private suspend fun updateNodeAndPersist(nodeNum: Int, transform: (Node) -> Node) {
val result = updateNodeState(nodeNum, channel = 0, transform)?.next ?: return
if (shouldPersist(result)) persistLatestNode(nodeNum)
}
@@ -450,6 +450,19 @@ class CommandSenderImplTest {
verify { neighborInfoHandler.recordStartTime(1) }
}
@Test
fun requestNeighborInfo_localNode_dummyReportsAnHourBroadcastIntervalInSeconds() = runTest {
every { neighborInfoHandler.lastNeighborInfo } returns null
val packets = mutableListOf<MeshPacket>()
everySuspend { packetHandler.sendToRadio(capture(packets)) } returns true
commandSender.requestNeighborInfo(requestId = 1, destNum = MY_NODE_NUM)
val sent = NeighborInfo.ADAPTER.decode(requireNotNull(packets.single().decoded).payload)
assertEquals(3600, sent.node_broadcast_interval_secs)
assertEquals(listOf(3600), sent.neighbors.map { it.node_broadcast_interval_secs })
}
@Test
fun requestNeighborInfo_remoteNode_sendsRequest() = runTest {
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns true
@@ -260,7 +260,6 @@ class MeshMessageProcessorImplTest {
advanceUntilIdle()
assertEquals(listOf(myNodeNum, 999), updatedNodes)
verifySuspend(mode = VerifyMode.exactly(0)) { nodeManager.updateNodeAndPersist(any(), any(), any()) }
}
@Test
@@ -207,7 +207,7 @@ class NodeManagerImplTest {
}
@Test
fun `updateNodeAndPersist awaits the repository write`() = testScope.runTest {
fun `persisting a node update awaits the repository write`() = testScope.runTest {
val nodeNum = 1234
nodeManager.setNodeDbReady(true)
nodeManager.setAllowNodeDbWrites(true)
@@ -219,14 +219,14 @@ class NodeManagerImplTest {
releaseWrite.await()
}
val update = async { nodeManager.updateNodeAndPersist(nodeNum) { node -> node.copy(lastHeard = 42) } }
val update = async { nodeManager.updateNodeStatusAndPersist(nodeNum, "away") }
writeStarted.await()
assertFalse(update.isCompleted)
releaseWrite.complete(Unit)
update.await()
verifySuspend { nodeRepository.upsert(any()) }
assertEquals(42, nodeManager.nodeDBbyNodeNum[nodeNum]?.lastHeard)
assertEquals("away", nodeManager.nodeDBbyNodeNum[nodeNum]?.nodeStatus)
}
@Test
@@ -254,12 +254,10 @@ class NodeManagerImplTest {
persisted += node
}
val first = async { nodeManager.updateNodeAndPersist(nodeNum) { node -> node.copy(lastHeard = 1) } }
val first = async { nodeManager.updateNodeStatusAndPersist(nodeNum, "first") }
firstWriteStarted.await()
val second =
async(start = CoroutineStart.UNDISPATCHED) {
nodeManager.updateNodeAndPersist(nodeNum) { node -> node.copy(lastHeard = 2) }
}
async(start = CoroutineStart.UNDISPATCHED) { nodeManager.updateNodeStatusAndPersist(nodeNum, "second") }
runCurrent()
assertFalse(
secondWriteStarted.isCompleted,
@@ -270,8 +268,8 @@ class NodeManagerImplTest {
first.await()
second.await()
assertEquals(listOf(1, 2), persisted.map(Node::lastHeard))
assertEquals(2, nodeManager.nodeDBbyNodeNum[nodeNum]?.lastHeard)
assertEquals(listOf("first", "second"), persisted.map(Node::nodeStatus))
assertEquals("second", nodeManager.nodeDBbyNodeNum[nodeNum]?.nodeStatus)
}
private fun admitLeases(session: RadioSessionContext) {
@@ -387,10 +385,10 @@ class NodeManagerImplTest {
nodeManager.setNodeDbReady(true)
nodeManager.setAllowNodeDbWrites(false)
nodeManager.updateNodeAndPersist(nodeNum) { node -> node.copy(lastHeard = 42) }
nodeManager.updateNodeStatusAndPersist(nodeNum, "away")
verifySuspend(exactly(0)) { nodeRepository.upsert(any()) }
assertEquals(42, nodeManager.nodeDBbyNodeNum[nodeNum]?.lastHeard)
assertEquals("away", nodeManager.nodeDBbyNodeNum[nodeNum]?.nodeStatus)
}
@Test
@@ -40,6 +40,7 @@ import kotlinx.io.IOException
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.anonymizePublicHost
import org.meshtastic.proto.ToRadio
import kotlin.concurrent.Volatile
@@ -101,7 +102,6 @@ class TcpTransport(
/** TCP connect timeout. A failed connect just feeds the reconnect/backoff loop, so it is not fatal. */
const val CONNECT_TIMEOUT_MS = 30_000L
private const val READ_BUFFER_SIZE = 1024
private const val MILLIS_PER_SECOND = 1_000L
/**
* Minimum session duration for backoff to reset. Sessions shorter than this that ended in peer-EOF are treated
@@ -247,14 +247,14 @@ class TcpTransport(
retryCount = 1
backoff = MIN_BACKOFF_MILLIS
} else if (hadData) {
val backoffSec = backoff / MILLIS_PER_SECOND
val backoffSec = backoff / TimeConstants.MS_PER_SEC
Logger.d {
"$logTag: [${address.anonymizePublicHost()}] Short session (${sessionUptime}ms); " +
"keeping backoff at ${backoffSec}s"
}
}
val delaySec = backoff / MILLIS_PER_SECOND
val delaySec = backoff / TimeConstants.MS_PER_SEC
Logger.i { "$logTag: [${address.anonymizePublicHost()}] Reconnect #$retryCount in ${delaySec}s" }
delay(backoff)
retryCount++
@@ -183,16 +183,6 @@ interface NodeManager : NodeIdLookup {
/** Session-bound counterpart to [updateNode]; deferred persistence is admitted only for [session]. */
fun updateNodeForSession(nodeNum: Int, session: RadioSessionContext, channel: Int = 0, transform: (Node) -> Node)
/**
* Updates a node using a side-effect-free [transform] and awaits any required persistence before returning. The
* transform may be evaluated more than once after compare-and-set contention.
*
* Session-scoped packet processing uses this while holding transport authority so an old session cannot enqueue a
* database write that resumes after a device switch. Non-session UI and controller updates continue to use
* [updateNode].
*/
suspend fun updateNodeAndPersist(nodeNum: Int, channel: Int = 0, transform: (Node) -> Node)
/** Removes a node from the in-memory database by its number. */
fun removeByNodenum(nodeNum: Int)
@@ -35,9 +35,9 @@ import org.meshtastic.core.resources.firmware_update_rebooting
import org.meshtastic.core.resources.firmware_update_retrieval_failed
import org.meshtastic.core.resources.firmware_update_usb_failed
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.feature.firmware.ota.formatTransferPercent
private const val USB_REBOOT_DELAY = 5000L
private const val PERCENT_MAX = 100
/**
* One leg of a multi-pass USB/UF2 sequence.
@@ -94,13 +94,12 @@ internal suspend fun performUsbMaintenance(
val firmware =
try {
retrieveUsbFirmware(release, hardware) { progress ->
val percent = (progress * PERCENT_MAX).toInt()
updateState(
FirmwareUpdateState.Downloading(
ProgressState(
message = UiText.DynamicString(downloadingMsg),
progress = progress,
details = UiText.DynamicString("$percent%"),
details = formatTransferPercent(progress),
),
),
)
@@ -373,13 +372,12 @@ internal suspend fun performUsbUpdate(
} else {
val firmwareFile =
retrieveUsbFirmware(release, hardware) { progress ->
val percent = (progress * PERCENT_MAX).toInt()
updateState(
FirmwareUpdateState.Downloading(
ProgressState(
message = UiText.DynamicString(downloadingMsg),
progress = progress,
details = UiText.DynamicString("$percent%"),
details = formatTransferPercent(progress),
),
),
)
@@ -59,7 +59,6 @@ import org.meshtastic.feature.firmware.ProgressState
import org.meshtastic.feature.firmware.stripFormatArgs
private const val RETRY_DELAY = 2000L
private const val PERCENT_MAX = 100
private const val REBOOT_MODE_BLE = 1
private const val REBOOT_MODE_WIFI = 2
@@ -361,13 +360,12 @@ class Esp32OtaUpdateHandler(
val firmwareFile =
firmwareRetriever.retrieveEsp32Firmware(release, hardware) { progress ->
val percent = (progress * PERCENT_MAX).toInt()
updateState(
FirmwareUpdateState.Downloading(
ProgressState(
message = UiText.DynamicString(downloadingMsg),
progress = progress,
details = UiText.DynamicString("$percent%"),
details = formatTransferPercent(progress),
),
),
)
@@ -26,6 +26,10 @@ import org.meshtastic.core.resources.firmware_update_transfer_progress
private const val PERCENT_MAX = 100
/** Formats download or transfer progress as a translated bare percentage, e.g. `"42%"` in English. */
internal fun formatTransferPercent(progress: Float): UiText =
UiText.Resource(Res.string.firmware_update_transfer_percent, (progress * PERCENT_MAX).toInt())
/**
* Formats firmware-transfer progress as translated detail text, e.g. `"42% (12.60 kB/s, ETA: 5s)"` in English, with the
* rate in decimal units.
@@ -34,8 +38,8 @@ private const val PERCENT_MAX = 100
* parentheses. Shared by the ESP32 OTA and Nordic DFU update handlers, which differ only in how they obtain the inputs.
*/
internal fun formatTransferProgress(progress: Float, totalBytes: Int, bytesPerSecond: Long): UiText {
if (bytesPerSecond <= 0L) return formatTransferPercent(progress)
val percent = (progress * PERCENT_MAX).toInt()
if (bytesPerSecond <= 0L) return UiText.Resource(Res.string.firmware_update_transfer_percent, percent)
val bytesSent = (progress * totalBytes).toLong()
val etaSeconds = ((totalBytes - bytesSent).toFloat() / bytesPerSecond).toInt()
return UiText.Resource(
@@ -55,6 +55,7 @@ import org.meshtastic.feature.firmware.FirmwareUpdateState
import org.meshtastic.feature.firmware.ProgressState
import org.meshtastic.feature.firmware.ota.ThroughputTracker
import org.meshtastic.feature.firmware.ota.calculateMacPlusOne
import org.meshtastic.feature.firmware.ota.formatTransferPercent
import org.meshtastic.feature.firmware.ota.formatTransferProgress
import org.meshtastic.feature.firmware.ota.retryWithDelay
import org.meshtastic.feature.firmware.ota.scanForBleDevice
@@ -62,7 +63,6 @@ import org.meshtastic.feature.firmware.stripFormatArgs
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
private const val PERCENT_MAX = 100
private const val GATT_RELEASE_DELAY_MS = 1_500L
private const val DFU_REBOOT_WAIT_MS = 3_000L
private const val RETRY_DELAY_MS = 2_000L
@@ -834,10 +834,9 @@ class SecureDfuHandler(
val path =
firmwareRetriever.retrieveOtaFirmware(release, hardware) { progress ->
val pct = (progress * PERCENT_MAX).toInt()
updateState(
FirmwareUpdateState.Downloading(
ProgressState(UiText.DynamicString(downloadingMsg), progress, UiText.DynamicString("$pct%")),
ProgressState(UiText.DynamicString(downloadingMsg), progress, formatTransferPercent(progress)),
),
)
}
@@ -20,11 +20,14 @@ import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.FirmwareRelease
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.firmware_update_transfer_percent
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@@ -208,16 +211,37 @@ abstract class CommonPerformUsbUpdateTest {
val downloadingStates = states.filterIsInstance<FirmwareUpdateState.Downloading>()
assertTrue(downloadingStates.size >= 2, "Expected multiple Downloading states for progress updates")
assertTrue(
downloadingStates.any { it.progressState.details == UiText.DynamicString("25%") },
"Expected 25% progress detail",
)
assertTrue(
downloadingStates.any { it.progressState.details == UiText.DynamicString("75%") },
"Expected 75% progress detail",
assertEquals(
listOf(percentDetail(25), percentDetail(75)),
downloadingStates.mapNotNull { it.progressState.details },
)
}
@Test
fun `maintenance download reports progress as a translated percentage`() = runTest {
val states = mutableListOf<FirmwareUpdateState>()
performUsbMaintenance(
request = UsbMaintenanceRequest.FactoryErase,
release = testRelease,
hardware = testHardware,
radioController = FakeRadioController(),
nodeRepository = FakeNodeRepository(),
updateState = { states.add(it) },
retrieveUsbFirmware = { _, _, onProgress ->
onProgress(0.4f)
null
},
)
assertEquals(
listOf(percentDetail(40)),
states.filterIsInstance<FirmwareUpdateState.Downloading>().mapNotNull { it.progressState.details },
)
}
private fun percentDetail(percent: Int) = UiText.Resource(Res.string.firmware_update_transfer_percent, percent)
@Test
fun `download path returns artifact for caller cleanup`() = runTest {
val radioController = FakeRadioController()
@@ -0,0 +1,124 @@
/*
* 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.feature.firmware.ota
import dev.mokkery.MockMode
import dev.mokkery.mock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.FirmwareRelease
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.firmware_update_transfer_percent
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.feature.firmware.FirmwareArtifact
import org.meshtastic.feature.firmware.FirmwareFileHandler
import org.meshtastic.feature.firmware.FirmwareRetriever
import org.meshtastic.feature.firmware.FirmwareUpdateState
import org.meshtastic.feature.firmware.ota.dfu.SecureDfuHandler
import kotlin.test.Test
import kotlin.test.assertEquals
/** The download step of the ESP32 and Nordic handlers, which resolves compose resources and so runs on the JVM. */
class DownloadProgressDetailTest {
private val release = FirmwareRelease(id = "v2.7.17", title = "test")
private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill)
private val dispatchers =
CoroutineDispatchers(
io = Dispatchers.Unconfined,
main = Dispatchers.Unconfined,
default = Dispatchers.Unconfined,
)
/** Reports a quarter of the download, then finds no file, so each handler stops right after its download step. */
private val retriever =
object : FirmwareRetriever(fileHandler) {
override suspend fun retrieveEsp32Firmware(
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
): FirmwareArtifact? {
onProgress(QUARTER)
return null
}
override suspend fun retrieveOtaFirmware(
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
): FirmwareArtifact? {
onProgress(QUARTER)
return null
}
}
@Test
fun `ESP32 download progress is a translated percentage`() = runTest {
val handler =
Esp32OtaUpdateHandler(
firmwareRetriever = retriever,
firmwareFileHandler = fileHandler,
radioController = FakeRadioController(),
nodeRepository = FakeNodeRepository(),
firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository(),
environment = DefaultEsp32OtaUpdateEnvironment(),
bleScanner = mock(MockMode.autofill),
bleConnectionFactory = mock(MockMode.autofill),
dispatchers = dispatchers,
)
val states = mutableListOf<FirmwareUpdateState>()
handler.startUpdate(release, ESP32, target = BLE_ADDRESS, updateState = states::add, firmwareUri = null)
assertEquals(listOf(percentDetail(25)), states.downloadDetails())
}
@Test
fun `Nordic DFU download progress is a translated percentage`() = runTest {
val handler =
SecureDfuHandler(
firmwareRetriever = retriever,
firmwareFileHandler = fileHandler,
radioController = FakeRadioController(),
bleScanner = mock(MockMode.autofill),
bleConnectionFactory = mock(MockMode.autofill),
dispatchers = dispatchers,
)
val states = mutableListOf<FirmwareUpdateState>()
handler.startUpdate(release, NRF52, target = BLE_ADDRESS, updateState = states::add, firmwareUri = null)
assertEquals(listOf(percentDetail(25)), states.downloadDetails())
}
private fun List<FirmwareUpdateState>.downloadDetails() =
filterIsInstance<FirmwareUpdateState.Downloading>().mapNotNull { it.progressState.details }
private fun percentDetail(percent: Int) = UiText.Resource(Res.string.firmware_update_transfer_percent, percent)
private companion object {
const val QUARTER = 0.25f
const val BLE_ADDRESS = "AA:BB:CC:DD:EE:FF"
val ESP32 = DeviceHardware(hwModelSlug = "HELTEC_V3", platformioTarget = "heltec-v3", architecture = "esp32-s3")
val NRF52 = DeviceHardware(hwModelSlug = "RAK4631", platformioTarget = "rak4631", architecture = "nrf52840")
}
}
@@ -61,5 +61,12 @@ class TransferProgressTextTest {
assertEquals("50%", formatTransferProgress(progress = 0.5f, totalBytes = 1000, bytesPerSecond = 0).resolve())
}
@Test
fun `download progress reads as the bare percentage`() = runTest {
Locale.setDefault(Locale.US)
assertEquals("25%", formatTransferPercent(progress = 0.25f).resolve())
}
private fun sample() = formatTransferProgress(progress = 0.5f, totalBytes = 2_000_000, bytesPerSecond = 12_600)
}