From d46fb013ac2fca80d40f5b3c8d356c31c8d0a710 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 26 Mar 2026 09:19:58 +0100 Subject: [PATCH] Unit tests + Fix start datafeed behaviour --- server/core/build.gradle.kts | 1 + .../main/java/dev/slimevr/firmware/server.kt | 28 ++ .../main/java/dev/slimevr/solarxr/datafeed.kt | 57 ++-- .../main/java/dev/slimevr/solarxr/firmware.kt | 2 +- .../main/java/dev/slimevr/solarxr/serial.kt | 1 - .../main/java/dev/slimevr/tracker/device.kt | 1 - .../src/test/java/dev/slimevr/TestServer.kt | 31 ++ .../dev/slimevr/firmware/DoSerialFlashTest.kt | 315 ++++++++++++++++++ .../reducers/FirmwareManagerReducerTest.kt | 80 +++++ .../dev/slimevr/serial/SerialServerTest.kt | 199 +++++++++++ .../reducers/SerialConnectionReducerTest.kt | 54 +++ .../java/dev/slimevr/solarxr/DataFeedTest.kt | 87 +++++ solarxr-protocol | 2 +- 13 files changed, 822 insertions(+), 36 deletions(-) create mode 100644 server/core/src/test/java/dev/slimevr/TestServer.kt create mode 100644 server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt create mode 100644 server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt create mode 100644 server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt create mode 100644 server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt create mode 100644 server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index 700aa3ab6..d51cfae27 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -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 { diff --git a/server/core/src/main/java/dev/slimevr/firmware/server.kt b/server/core/src/main/java/dev/slimevr/firmware/server.kt index bec07afb2..cf5a8cbe4 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/server.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/server.kt @@ -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 diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 914f7155f..9636735d9 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -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 { 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 { event -> diff --git a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt index 012c7b890..215006d4e 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt @@ -47,7 +47,7 @@ val FirmwareBehaviour = SolarXRConnectionBehaviour( method.needmanualreboot, method.ssid, method.password, - conn.serverContext + conn.serverContext, ) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt index 4ce356490..a666b6803 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -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 diff --git a/server/core/src/main/java/dev/slimevr/tracker/device.kt b/server/core/src/main/java/dev/slimevr/tracker/device.kt index d8cc19b05..615487abf 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/device.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/device.kt @@ -14,7 +14,6 @@ enum class DeviceOrigin { FEEDER, UDP, HID, - SERIAL, } data class DeviceState( diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt new file mode 100644 index 000000000..757fd7f29 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -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)) +} \ No newline at end of file diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt new file mode 100644 index 000000000..231802e83 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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()) + } +} diff --git a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt new file mode 100644 index 000000000..f6a363973 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt b/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt new file mode 100644 index 000000000..705a30dab --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt @@ -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(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(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(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(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(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(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"]) + } +} diff --git a/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt b/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt new file mode 100644 index 000000000..15198bec9 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt @@ -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 = 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) + } +} \ No newline at end of file diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt new file mode 100644 index 000000000..994b32ccc --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -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 +} diff --git a/solarxr-protocol b/solarxr-protocol index f784b4bb0..6ef8a144f 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit f784b4bb034b1c588e9e0cefdd909c7ee5844dfc +Subproject commit 6ef8a144ffc57bcadf5168740aa0b49c50faa823