mirror of
https://github.com/SlimeVR/SlimeVR-Server.git
synced 2026-04-06 02:01:58 +02:00
Unit tests + Fix start datafeed behaviour
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -47,7 +47,7 @@ val FirmwareBehaviour = SolarXRConnectionBehaviour(
|
||||
method.needmanualreboot,
|
||||
method.ssid,
|
||||
method.password,
|
||||
conn.serverContext
|
||||
conn.serverContext,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,7 +14,6 @@ enum class DeviceOrigin {
|
||||
FEEDER,
|
||||
UDP,
|
||||
HID,
|
||||
SERIAL,
|
||||
}
|
||||
|
||||
data class DeviceState(
|
||||
|
||||
31
server/core/src/test/java/dev/slimevr/TestServer.kt
Normal file
31
server/core/src/test/java/dev/slimevr/TestServer.kt
Normal 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))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
199
server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt
Normal file
199
server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt
Normal 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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Submodule solarxr-protocol updated: f784b4bb03...6ef8a144ff
Reference in New Issue
Block a user