Unit tests + Fix start datafeed behaviour

This commit is contained in:
loucass003
2026-03-26 09:19:58 +01:00
parent 8ffd00eb47
commit d46fb013ac
13 changed files with 822 additions and 36 deletions

View File

@@ -89,6 +89,7 @@ dependencies {
testImplementation(platform("org.junit:junit-bom:6.0.2"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.junit.platform:junit-platform-launcher")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
tasks.test {

View File

@@ -125,6 +125,34 @@ suspend fun doSerialFlash(
return
}
doSerialFlashPostFlash(
portLocation = portLocation,
needManualReboot = needManualReboot,
ssid = ssid,
password = password,
serialServer = serialServer,
server = server,
onStatus = onStatus,
)
}
/**
* Handles the post-flash provisioning phase: reconnects the serial console,
* reads the device MAC address, sends Wi-Fi credentials, and waits for the
* tracker to appear on the network.
*
* Separated from [doSerialFlash] so it can also be exercised independently for
* unit tests
*/
internal suspend fun doSerialFlashPostFlash(
portLocation: String,
needManualReboot: Boolean,
ssid: String?,
password: String?,
serialServer: SerialServer,
server: VRServer,
onStatus: suspend (FirmwareUpdateStatus, Int) -> Unit,
) {
onStatus(
if (needManualReboot) {
FirmwareUpdateStatus.NEED_MANUAL_REBOOT

View File

@@ -5,11 +5,9 @@ import dev.slimevr.VRServer
import dev.slimevr.tracker.DeviceState
import dev.slimevr.tracker.TrackerState
import io.ktor.util.moveToByteArray
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import solarxr_protocol.MessageBundle
import solarxr_protocol.data_feed.DataFeedConfig
@@ -25,7 +23,6 @@ import solarxr_protocol.datatypes.DeviceId
import solarxr_protocol.datatypes.TrackerId
import solarxr_protocol.datatypes.hardware_info.HardwareStatus
import solarxr_protocol.datatypes.math.Quat
import kotlin.time.measureTime
private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask): TrackerData = TrackerData(
trackerId = TrackerId(
@@ -72,6 +69,7 @@ private fun createDevice(
fun createDatafeedFrame(
serverContext: VRServer,
datafeedConfig: DataFeedConfig,
index: Int = 0,
): DataFeedMessageHeader {
val serverState = serverContext.context.state.value
val trackers =
@@ -82,45 +80,44 @@ fun createDatafeedFrame(
return DataFeedMessageHeader(
message = DataFeedUpdate(
devices = if (datafeedConfig.dataMask?.deviceData != null) devices else null,
index = index.toUByte(),
),
)
}
val DataFeedInitBehaviour = SolarXRConnectionBehaviour(
reducer = { s, a ->
when (a) {
is SolarXRConnectionActions.SetConfig -> s.copy(
dataFeedConfigs = a.configs,
datafeedTimers = a.timers,
)
}
},
observer = { context ->
context.dataFeedDispatcher.on<StartDataFeed> { event ->
val datafeeds = event.dataFeeds ?: return@on
val state = context.context.state.value
state.datafeedTimers.forEach {
it.cancelAndJoin()
}
context.context.state.value.datafeedTimers.forEach { it.cancelAndJoin() }
val timers = datafeeds.map { config ->
val minTime = config.minimumTimeSinceLast ?: return@map null
return@map context.context.scope.launch(start = CoroutineStart.LAZY) {
val timers = datafeeds.mapIndexed { index, config ->
context.context.scope.launch {
val fbb = FlatBufferBuilder(1024)
val minTime = config.minimumTimeSinceLast.toLong()
while (isActive) {
val timeTaken = measureTime {
fbb.clear()
fbb.finish(
MessageBundle(
dataFeedMsgs = listOf(
createDatafeedFrame(serverContext = context.serverContext, datafeedConfig = config),
),
).encode(fbb),
)
context.send(fbb.dataBuffer().moveToByteArray())
}
val remainingDelay = (minTime.toLong() - timeTaken.inWholeMilliseconds)
assert(remainingDelay > 0)
delay(remainingDelay)
fbb.clear()
fbb.finish(
MessageBundle(
dataFeedMsgs = listOf(
createDatafeedFrame(context.serverContext, config, index),
),
).encode(fbb),
)
context.send(fbb.dataBuffer().moveToByteArray())
delay(minTime)
}
}
}.filterNotNull()
}
context.context.dispatch(
SolarXRConnectionActions.SetConfig(
@@ -128,10 +125,6 @@ val DataFeedInitBehaviour = SolarXRConnectionBehaviour(
timers = timers,
),
)
context.context.scope.launch {
timers.joinAll()
}
}
context.dataFeedDispatcher.on<PollDataFeed> { event ->

View File

@@ -47,7 +47,7 @@ val FirmwareBehaviour = SolarXRConnectionBehaviour(
method.needmanualreboot,
method.ssid,
method.password,
conn.serverContext
conn.serverContext,
)
}

View File

@@ -21,7 +21,6 @@ import solarxr_protocol.rpc.SerialTrackerGetWifiScanRequest
import solarxr_protocol.rpc.SerialTrackerRebootRequest
import solarxr_protocol.rpc.SerialUpdateResponse
val SerialConsoleBehaviour = SolarXRConnectionBehaviour(
observer = { conn ->
val scope = conn.context.scope

View File

@@ -14,7 +14,6 @@ enum class DeviceOrigin {
FEEDER,
UDP,
HID,
SERIAL,
}
data class DeviceState(

View File

@@ -0,0 +1,31 @@
package dev.slimevr
import dev.llelievr.espflashkotlin.FlasherSerialInterface
import dev.slimevr.firmware.createFirmwareManager
import dev.slimevr.serial.SerialPortHandle
import dev.slimevr.serial.SerialServer
import kotlinx.coroutines.CoroutineScope
fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create(
openPort = { loc, _, _, _ -> SerialPortHandle(loc, "Fake $loc", {}, {}) },
openFlashingPort = {
object : FlasherSerialInterface {
override fun openSerial(port: Any) = Unit
override fun closeSerial() = Unit
override fun write(data: ByteArray) = Unit
override fun read(length: Int) = ByteArray(length)
override fun setDTR(value: Boolean) = Unit
override fun setRTS(value: Boolean) = Unit
override fun changeBaud(baud: Int) = Unit
override fun setReadTimeout(timeout: Long) = Unit
override fun availableBytes() = 0
override fun flushIOBuffers() = Unit
}
},
scope = scope,
)
fun buildTestVrServer(scope: CoroutineScope): VRServer {
val serialServer = buildTestSerialServer(scope)
return VRServer.create(scope, serialServer, createFirmwareManager(serialServer, scope))
}

View File

@@ -0,0 +1,315 @@
package dev.slimevr.firmware
import dev.llelievr.espflashkotlin.FlasherSerialInterface
import dev.slimevr.VRServer
import dev.slimevr.VRServerActions
import dev.slimevr.serial.SerialPortHandle
import dev.slimevr.serial.SerialPortInfo
import dev.slimevr.serial.SerialServer
import dev.slimevr.tracker.DeviceOrigin
import dev.slimevr.tracker.createDevice
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import solarxr_protocol.rpc.FirmwareUpdateStatus
import kotlin.test.Test
import kotlin.test.assertEquals
private fun fakePortHandle(loc: String) = SerialPortHandle(
portLocation = loc,
descriptivePortName = "Fake $loc",
writeCommand = {},
close = {},
)
private fun fakePort(loc: String = "COM1") = SerialPortInfo(loc, "Fake $loc", 0x1A86, 0x7523)
/** Fails immediately at openSerial so the Flasher throws with no IO delays */
private fun failingFlashHandler() = object : FlasherSerialInterface {
override fun openSerial(port: Any) = error("simulated flash failure")
override fun closeSerial() {}
override fun write(data: ByteArray) {}
override fun read(length: Int) = ByteArray(length)
override fun setDTR(value: Boolean) {}
override fun setRTS(value: Boolean) {}
override fun changeBaud(baud: Int) {}
override fun setReadTimeout(timeout: Long) {}
override fun availableBytes() = 0
override fun flushIOBuffers() {}
}
private fun buildSerialServer(
scope: kotlinx.coroutines.CoroutineScope,
flashHandler: () -> FlasherSerialInterface = ::failingFlashHandler,
) = SerialServer.create(
openPort = { loc, _, _, _ -> fakePortHandle(loc) },
openFlashingPort = flashHandler,
scope = scope,
)
// VRServer's BaseBehaviour sets up infinite StateFlow collectors via launchIn(scope).
// backgroundScope lets those run on the test scheduler but doesn't cause
// UncompletedCoroutinesError when the test ends.
private fun buildVrServer(
mainScope: kotlinx.coroutines.CoroutineScope,
backgroundScope: kotlinx.coroutines.CoroutineScope,
serialServer: SerialServer,
): VRServer {
val firmwareManager = createFirmwareManager(serialServer, mainScope)
return VRServer.create(backgroundScope, serialServer, firmwareManager)
}
class DoSerialFlashTest {
@Test
fun `emits ERROR_DEVICE_NOT_FOUND when port is not available`() = runTest {
val server = buildSerialServer(this)
val statuses = mutableListOf<FirmwareUpdateStatus>()
doSerialFlash(
portLocation = "COM1",
parts = emptyList(),
needManualReboot = false,
ssid = null,
password = null,
serialServer = server,
server = buildVrServer(this, backgroundScope, server),
onStatus = { s, _ -> statuses += s },
scope = this,
)
assertEquals(FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND, statuses.last())
}
@Test
fun `emits ERROR_DEVICE_NOT_FOUND when port already has a connection`() = runTest {
val server = buildSerialServer(this)
server.onPortDetected(fakePort())
server.openConnection("COM1")
val statuses = mutableListOf<FirmwareUpdateStatus>()
doSerialFlash(
portLocation = "COM1",
parts = emptyList(),
needManualReboot = false,
ssid = null,
password = null,
serialServer = server,
server = buildVrServer(this, backgroundScope, server),
onStatus = { s, _ -> statuses += s },
scope = this,
)
assertEquals(FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND, statuses.last())
}
@Test
fun `emits ERROR_UPLOAD_FAILED when flash throws`() = runTest {
val server = buildSerialServer(this, ::failingFlashHandler)
server.onPortDetected(fakePort())
val statuses = mutableListOf<FirmwareUpdateStatus>()
doSerialFlash(
portLocation = "COM1",
parts = emptyList(),
needManualReboot = false,
ssid = null,
password = null,
serialServer = server,
server = buildVrServer(this, backgroundScope, server),
onStatus = { s, _ -> statuses += s },
scope = this,
)
assertEquals(FirmwareUpdateStatus.ERROR_UPLOAD_FAILED, statuses.last())
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `emits ERROR_DEVICE_NOT_FOUND when device has not reconnected after flash`() = runTest {
// Port not back in availablePorts yet, openConnection inside doSerialFlashPostFlash is a no-op
val server = buildSerialServer(this)
val statuses = mutableListOf<FirmwareUpdateStatus>()
doSerialFlashPostFlash(
portLocation = "COM1",
needManualReboot = false,
ssid = "wifi",
password = "pass",
serialServer = server,
server = buildVrServer(this, backgroundScope, server),
onStatus = { s, _ -> statuses += s },
)
assertEquals(FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND, statuses.last())
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `emits ERROR_PROVISIONING_FAILED when MAC not received within timeout`() = runTest {
val server = buildSerialServer(this)
server.onPortDetected(fakePort())
server.openConnection("COM1")
val statuses = mutableListOf<FirmwareUpdateStatus>()
val job = launch {
doSerialFlashPostFlash(
portLocation = "COM1",
needManualReboot = false,
ssid = "wifi",
password = "pass",
serialServer = server,
server = buildVrServer(this, backgroundScope, server),
onStatus = { s, _ -> statuses += s },
)
}
advanceTimeBy(10_001)
job.join()
assertEquals(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED, statuses.last())
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `emits ERROR_PROVISIONING_FAILED when ssid or password is null`() = runTest {
val server = buildSerialServer(this)
server.onPortDetected(fakePort())
server.openConnection("COM1")
val statuses = mutableListOf<FirmwareUpdateStatus>()
launch {
doSerialFlashPostFlash(
portLocation = "COM1",
needManualReboot = false,
ssid = null,
password = null,
serialServer = server,
server = buildVrServer(this, backgroundScope, server),
onStatus = { s, _ -> statuses += s },
)
}
launch {
delay(100)
server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF")
}
advanceUntilIdle()
assertEquals(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED, statuses.last())
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `emits ERROR_PROVISIONING_FAILED when wifi does not connect within timeout`() = runTest {
val server = buildSerialServer(this)
server.onPortDetected(fakePort())
server.openConnection("COM1")
val statuses = mutableListOf<FirmwareUpdateStatus>()
val job = launch {
doSerialFlashPostFlash(
portLocation = "COM1",
needManualReboot = false,
ssid = "wifi",
password = "pass",
serialServer = server,
server = buildVrServer(this, backgroundScope, server),
onStatus = { s, _ -> statuses += s },
)
}
launch {
delay(100)
server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF")
}
// MAC arrives at 100ms; wifi timeout fires 30s later
advanceTimeBy(30_101)
job.join()
assertEquals(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED, statuses.last())
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `emits ERROR_TIMEOUT when tracker does not appear within timeout`() = runTest {
val server = buildSerialServer(this)
server.onPortDetected(fakePort())
server.openConnection("COM1")
val statuses = mutableListOf<FirmwareUpdateStatus>()
val job = launch {
doSerialFlashPostFlash(
portLocation = "COM1",
needManualReboot = false,
ssid = "wifi",
password = "pass",
serialServer = server,
server = buildVrServer(this, backgroundScope, server),
onStatus = { s, _ -> statuses += s },
)
}
launch {
delay(100)
server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF")
}
launch {
delay(200)
server.onDataReceived("COM1", "looking for the server")
}
// MAC at 100ms, wifi log at 200ms; tracker timeout fires 60s after wifi confirmed
advanceTimeBy(60_201)
job.join()
assertEquals(FirmwareUpdateStatus.ERROR_TIMEOUT, statuses.last())
}
// ── Full success path ─────────────────────────────────────────────────
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `emits DONE when everything succeeds`() = runTest {
val server = buildSerialServer(this)
server.onPortDetected(fakePort())
server.openConnection("COM1")
val vrServer = buildVrServer(this, backgroundScope, server)
val statuses = mutableListOf<FirmwareUpdateStatus>()
launch {
doSerialFlashPostFlash(
portLocation = "COM1",
needManualReboot = false,
ssid = "wifi",
password = "pass",
serialServer = server,
server = vrServer,
onStatus = { s, _ -> statuses += s },
)
}
launch {
delay(100)
server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF")
}
launch {
delay(200)
server.onDataReceived("COM1", "looking for the server")
}
launch {
delay(300)
val device = createDevice(backgroundScope, vrServer.nextHandle(), "AA:BB:CC:DD:EE:FF", DeviceOrigin.UDP, vrServer)
vrServer.context.dispatch(VRServerActions.NewDevice(device.context.state.value.id, device))
}
advanceUntilIdle()
assertEquals(FirmwareUpdateStatus.DONE, statuses.last())
}
}

View File

@@ -0,0 +1,80 @@
package dev.slimevr.firmware.reducers
import dev.slimevr.context.createContext
import dev.slimevr.firmware.FirmwareManagerActions
import dev.slimevr.firmware.FirmwareManagerBaseBehaviour
import dev.slimevr.firmware.FirmwareManagerState
import kotlinx.coroutines.test.runTest
import solarxr_protocol.rpc.FirmwareUpdateStatus
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class FirmwareManagerReducerTest {
private fun makeContext(scope: kotlinx.coroutines.CoroutineScope) = createContext(
initialState = FirmwareManagerState(jobs = mapOf()),
reducers = listOf(FirmwareManagerBaseBehaviour.reducer),
scope = scope,
)
@Test
fun `UpdateJob adds a new job`() = runTest {
val context = makeContext(this)
context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 42))
val job = context.state.value.jobs["COM1"]
assertNotNull(job)
assertEquals(FirmwareUpdateStatus.UPLOADING, job.status)
assertEquals(42, job.progress)
}
@Test
fun `UpdateJob replaces an existing job`() = runTest {
val context = makeContext(this)
context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.DOWNLOADING, 0))
context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 75))
val job = context.state.value.jobs["COM1"]
assertNotNull(job)
assertEquals(FirmwareUpdateStatus.UPLOADING, job.status)
assertEquals(75, job.progress)
assertEquals(1, context.state.value.jobs.size)
}
@Test
fun `UpdateJob tracks multiple ports independently`() = runTest {
val context = makeContext(this)
context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 10))
context.dispatch(FirmwareManagerActions.UpdateJob("COM2", FirmwareUpdateStatus.DOWNLOADING, 0))
assertEquals(2, context.state.value.jobs.size)
assertEquals(FirmwareUpdateStatus.UPLOADING, context.state.value.jobs["COM1"]?.status)
assertEquals(FirmwareUpdateStatus.DOWNLOADING, context.state.value.jobs["COM2"]?.status)
}
@Test
fun `RemoveJob removes an existing job`() = runTest {
val context = makeContext(this)
context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 50))
context.dispatch(FirmwareManagerActions.RemoveJob("COM1"))
assertNull(context.state.value.jobs["COM1"])
assertTrue(context.state.value.jobs.isEmpty())
}
@Test
fun `RemoveJob on unknown port is a no-op`() = runTest {
val context = makeContext(this)
context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 50))
context.dispatch(FirmwareManagerActions.RemoveJob("COM2"))
assertEquals(1, context.state.value.jobs.size)
}
}

View File

@@ -0,0 +1,199 @@
package dev.slimevr.serial
import dev.llelievr.espflashkotlin.FlasherSerialInterface
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
private fun fakePortHandle(portLocation: String) = SerialPortHandle(
portLocation = portLocation,
descriptivePortName = "Fake $portLocation",
writeCommand = {},
close = {},
)
private fun fakeFlashingHandler() = object : FlasherSerialInterface {
override fun openSerial(port: Any) {}
override fun closeSerial() {}
override fun write(data: ByteArray) {}
override fun read(length: Int) = ByteArray(length)
override fun setDTR(value: Boolean) {}
override fun setRTS(value: Boolean) {}
override fun changeBaud(baud: Int) {}
override fun setReadTimeout(timeout: Long) {}
override fun availableBytes() = 0
override fun flushIOBuffers() {}
}
private fun fakePort() = SerialPortInfo("COM1", "Fake COM1", 0x1A86, 0x7523)
class SerialServerTest {
@Test
fun `openForFlashing registers Flashing connection`() = runTest {
val server = SerialServer.create(
openPort = { loc, _, _, _ -> fakePortHandle(loc) },
openFlashingPort = ::fakeFlashingHandler,
scope = this,
)
server.onPortDetected(fakePort())
val handler = server.openForFlashing("COM1")
assertNotNull(handler)
assertIs<SerialConnection.Flashing>(server.context.state.value.connections["COM1"])
}
@Test
fun `openForFlashing returns null when port has an existing connection`() = runTest {
val server = SerialServer.create(
openPort = { loc, _, _, _ -> fakePortHandle(loc) },
openFlashingPort = ::fakeFlashingHandler,
scope = this,
)
server.onPortDetected(fakePort())
server.openConnection("COM1")
val handler = server.openForFlashing("COM1")
assertNull(handler)
}
@Test
fun `openForFlashing returns null for unknown port`() = runTest {
val server = SerialServer.create(
openPort = { loc, _, _, _ -> fakePortHandle(loc) },
openFlashingPort = ::fakeFlashingHandler,
scope = this,
)
// No onPortDetected call, port is not in availablePorts
val handler = server.openForFlashing("COM1")
assertNull(handler)
}
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
@Test
fun `closeSerial removes Flashing connection asynchronously`() = runTest {
val server = SerialServer.create(
openPort = { loc, _, _, _ -> fakePortHandle(loc) },
openFlashingPort = ::fakeFlashingHandler,
scope = this,
)
server.onPortDetected(fakePort())
val handler = server.openForFlashing("COM1")!!
handler.closeSerial()
// The scope.launch inside closeSerial has not run yet
assertIs<SerialConnection.Flashing>(server.context.state.value.connections["COM1"])
advanceUntilIdle()
// Now the dispatched RemoveConnection has run
assertNull(server.context.state.value.connections["COM1"])
}
@Test
fun `openConnection registers Console connection`() = runTest {
val server = SerialServer.create(
openPort = { loc, _, _, _ -> fakePortHandle(loc) },
openFlashingPort = ::fakeFlashingHandler,
scope = this,
)
server.onPortDetected(fakePort())
server.openConnection("COM1")
assertIs<SerialConnection.Console>(server.context.state.value.connections["COM1"])
}
@Test
fun `onPortLost closes Console and removes connection`() = runTest {
val server = SerialServer.create(
openPort = { loc, _, _, _ -> fakePortHandle(loc) },
openFlashingPort = ::fakeFlashingHandler,
scope = this,
)
server.onPortDetected(fakePort())
server.openConnection("COM1")
server.onPortLost("COM1")
assertNull(server.context.state.value.connections["COM1"])
assertNull(server.context.state.value.availablePorts["COM1"])
}
@Test
fun `openConnection while flashing is a no-op`() = runTest {
val server = SerialServer.create(
openPort = { loc, _, _, _ -> fakePortHandle(loc) },
openFlashingPort = ::fakeFlashingHandler,
scope = this,
)
server.onPortDetected(fakePort())
server.openForFlashing("COM1")
server.openConnection("COM1")
// Still Flashing, openConnection must not have replaced it
assertIs<SerialConnection.Flashing>(server.context.state.value.connections["COM1"])
}
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
@Test
fun `port can be flashed again after previous flash completes`() = runTest {
val server = SerialServer.create(
openPort = { loc, _, _, _ -> fakePortHandle(loc) },
openFlashingPort = ::fakeFlashingHandler,
scope = this,
)
server.onPortDetected(fakePort())
val firstHandler = server.openForFlashing("COM1")!!
firstHandler.closeSerial()
advanceUntilIdle()
// Connection is gone, port is still available, can flash again
val secondHandler = server.openForFlashing("COM1")
assertNotNull(secondHandler)
assertIs<SerialConnection.Flashing>(server.context.state.value.connections["COM1"])
}
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
@Test
fun `openConnection succeeds after flash completes`() = runTest {
val server = SerialServer.create(
openPort = { loc, _, _, _ -> fakePortHandle(loc) },
openFlashingPort = ::fakeFlashingHandler,
scope = this,
)
server.onPortDetected(fakePort())
val handler = server.openForFlashing("COM1")!!
handler.closeSerial()
advanceUntilIdle()
server.openConnection("COM1")
assertIs<SerialConnection.Console>(server.context.state.value.connections["COM1"])
}
@Test
fun `onPortLost during flash removes Flashing connection`() = runTest {
val server = SerialServer.create(
openPort = { loc, _, _, _ -> fakePortHandle(loc) },
openFlashingPort = ::fakeFlashingHandler,
scope = this,
)
server.onPortDetected(fakePort())
server.openForFlashing("COM1")
server.onPortLost("COM1")
assertNull(server.context.state.value.connections["COM1"])
assertNull(server.context.state.value.availablePorts["COM1"])
}
}

View File

@@ -0,0 +1,54 @@
package dev.slimevr.serial.reducers
import dev.slimevr.serial.SerialConnectionActions
import dev.slimevr.serial.SerialConnectionState
import dev.slimevr.serial.SerialLogBehaviour
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
class SerialConnectionReducerTest {
private val reducer = SerialLogBehaviour.reducer!!
private fun state(lines: List<String> = emptyList(), connected: Boolean = true) = SerialConnectionState(
portLocation = "COM1",
descriptivePortName = "Test Port",
connected = connected,
logLines = lines,
)
@Test
fun `LogLine appends to empty log`() {
val result = reducer(state(), SerialConnectionActions.LogLine("hello"))
assertEquals(listOf("hello"), result.logLines)
}
@Test
fun `LogLine appends to existing log`() {
val result = reducer(state(listOf("a", "b")), SerialConnectionActions.LogLine("c"))
assertEquals(listOf("a", "b", "c"), result.logLines)
}
@Test
fun `LogLine drops oldest line when at capacity`() {
val full = state(lines = List(500) { "line $it" })
val result = reducer(full, SerialConnectionActions.LogLine("new"))
assertEquals(500, result.logLines.size)
assertEquals("line 1", result.logLines.first())
assertEquals("new", result.logLines.last())
}
@Test
fun `LogLine does not drop below capacity`() {
val almostFull = state(lines = List(499) { "line $it" })
val result = reducer(almostFull, SerialConnectionActions.LogLine("new"))
assertEquals(500, result.logLines.size)
assertEquals("line 0", result.logLines.first())
}
@Test
fun `Disconnected sets connected to false`() {
val result = reducer(state(connected = true), SerialConnectionActions.Disconnected)
assertFalse(result.connected)
}
}

View File

@@ -0,0 +1,87 @@
package dev.slimevr.solarxr
import dev.slimevr.buildTestVrServer
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import solarxr_protocol.data_feed.DataFeedConfig
import solarxr_protocol.data_feed.PollDataFeed
import solarxr_protocol.data_feed.StartDataFeed
import kotlin.test.Test
import kotlin.test.assertEquals
private fun config(intervalMs: Int) = DataFeedConfig(minimumTimeSinceLast = intervalMs.toUShort())
@OptIn(ExperimentalCoroutinesApi::class)
class DataFeedTest {
@Test
fun `StartDataFeed sends frames at the configured interval`() = runTest {
var sendCount = 0
val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope)
conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100))))
// fires at t=0, t=100, t=200
advanceTimeBy(250)
assertEquals(3, sendCount)
}
@Test
fun `StartDataFeed with multiple configs runs each at its own frequency`() = runTest {
var sendCount = 0
val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope)
conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100), config(200))))
// 100ms feed: t=0, t=100, t=200 -> 3 sends
// 200ms feed: t=0, t=200 -> 2 sends
advanceTimeBy(250)
assertEquals(5, sendCount)
}
@Test
fun `PollDataFeed sends exactly one frame without starting a repeating timer`() = runTest {
var sendCount = 0
val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope)
conn.dataFeedDispatcher.emit(PollDataFeed(config = config(100)))
advanceTimeBy(500)
assertEquals(1, sendCount)
}
@Test
fun `StartDataFeed cancels old timers when called a second time`() = runTest {
var sendCount = 0
val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope)
conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100))))
advanceTimeBy(250)
assertEquals(3, sendCount)
conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100))))
sendCount = 0
advanceTimeBy(250)
assertEquals(3, sendCount)
}
@Test
fun `StartDataFeed with empty list stops all existing timers`() = runTest {
var sendCount = 0
val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope)
conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100))))
advanceTimeBy(250)
assertEquals(3, sendCount)
conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = emptyList()))
sendCount = 0
advanceTimeBy(500)
assertEquals(0, sendCount)
}
//TODO: need more tests for the content of a datafeed + check if the masks work
}