package com.hawhamburg.micr0bu import com.hawhamburg.micr0bu.domain.asn1.SpatemUperCodec import com.hawhamburg.micr0bu.domain.spat.SignalPhase import com.hawhamburg.micr0bu.domain.spat.SignalPhaseEvent import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** * Regression tests for [SpatemUperCodec], using real SPATEMs as fixtures. * * ## Where the fixtures come from * Both are genuine over-the-air messages, taken from the protobuf-wrapped UPER the OBU publishes * (field 3 of the blob) - one from the live bench RSU trigger, one from the 2026-03-18 drive * recorded in `its-g5-receiver-firmware/recordings/its_messages_*.db`. * * ## Why the expected values can be trusted * The bit layout was validated by replaying **79,042 real SPATEMs** - the whole drive across 7+ * RSUs plus the live trigger - through a port of this decoder and comparing every field against * `asn1tools` decoding the same bytes with the ETSI modules from the `C-ITS-Parser` checkout. * All 79,042 matched exactly, and none hit an unsupported branch. The values asserted below are * that independent decoder's output, not this codebase's own arithmetic. * * The two fixtures are deliberately different shapes: the live one is minimal (no region, no * maneuverAssistList), the recorded one exercises `region`, a 7-entry event list, and the * variable-length `maneuverAssistList` that has to be walked to find the next field. */ class SpatemUperCodecTest { /** * Live bench RSU, 58 bytes: one intersection (id 23, no region), two signal groups. * This is the smallest shape seen in practice. */ private val liveSpatem = "020439b9de89451672018000b81040051672adb401001143707ff07ff07ff7a23840484048404bc00851dc1fd41fd41f" + "d5e86e112a112a112af0" /** * Real RSU from the drive, 251 bytes: region 3 / intersection 121, four signal groups, up to * seven predicted phases each, and a maneuverAssistList. */ private val recordedSpatem = "0204001233b441ae520188001803c8402001ae527d6b032016467032f0424039d2a43819f021981d6150dc0ed812ac10" + "91088e077609600852846703f705dc04fb3a43820302f582851d0dc120c19a0161d020008004404c8ae065e0848073a5" + "477033e043303ac2a1b81db025582122111c0eec12c010a508ae07ee0bb809f67477040605eb050a3a1b8241833402c3" + "a04003001480d919c0cbc10900e74a90e067c08660758543703b604ab0424422381dd82580214a119c0fdc177013ece9" + "0e080c0bd60a1474370483066805874080080031021a1b81a6821201d89919c0dfc11580f8ce86e08200b0409f694670" + "44205b9052d48801800680" private val receivedAt = 1_787_100_000_000L private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray() @Test fun `decodes the live bench SPATEM`() { val spat = SpatemUperCodec.decode(liveSpatem.hexToBytes(), receivedAt, rssiDbm = -55) assertNotNull("real captured SPATEM must decode", spat) spat!! assertEquals(968_482_441L, spat.stationId) assertEquals(333_426, spat.minuteOfYear) assertEquals(-55, spat.rssiDbm) assertEquals(1, spat.intersections.size) val i = spat.intersections[0] assertNull("this RSU sends no RoadRegulatorID", i.region) assertEquals(23, i.id) assertEquals(1, i.revision) assertEquals(333_426, i.moy) assertEquals(44_468, i.timeStampMs) assertEquals("-1/23", i.key) assertEquals(2, i.movements.size) val sg1 = i.movements[0] assertEquals(1, sg1.signalGroup) assertEquals(2, sg1.events.size) assertEquals(SignalPhase.STOP_AND_REMAIN, sg1.current!!.phase) assertEquals(4094, sg1.current!!.minEndTimeDs) val sg2 = i.movements[1] assertEquals(2, sg2.signalGroup) assertEquals(SignalPhase.PERMISSIVE_CLEARANCE, sg2.current!!.phase) assertEquals(4074, sg2.current!!.minEndTimeDs) } @Test fun `decodes a real RSU SPATEM with region and maneuverAssistList`() { val spat = SpatemUperCodec.decode(recordedSpatem.hexToBytes(), receivedAt) assertNotNull(spat) spat!! assertEquals(1_192_884L, spat.stationId) assertEquals(1, spat.intersections.size) val i = spat.intersections[0] // IntersectionID is only unique within a RoadRegulatorID, so the region must survive // decoding - the drive contains the same intersection id under different regions. assertEquals(3, i.region) assertEquals(121, i.id) assertEquals(4, i.revision) assertEquals(110_162, i.moy) assertEquals(32_107, i.timeStampMs) assertEquals("3/121", i.key) assertEquals(4, i.movements.size) assertEquals(listOf(1, 2, 3, 4), i.movements.map { it.signalGroup }) // A 7-entry prediction list: the current phase plus the upcoming sequence. val sg1 = i.movements[0] assertEquals(7, sg1.events.size) assertEquals(SignalPhase.PROTECTED_MOVEMENT_ALLOWED, sg1.current!!.phase) assertEquals(1630, sg1.current!!.minEndTimeDs) assertEquals(2120, sg1.current!!.maxEndTimeDs) assertEquals(1850, sg1.current!!.likelyTimeDs) assertEquals(SignalPhase.PERMISSIVE_MOVEMENT_ALLOWED, i.movements[1].current!!.phase) assertEquals(SignalPhase.STOP_AND_REMAIN, i.movements[3].current!!.phase) assertEquals(4, i.movements[3].events.size) } @Test fun `phase helpers classify the states a driver cares about`() { assertTrue(SignalPhase.PROTECTED_MOVEMENT_ALLOWED.isGo) assertTrue(SignalPhase.PERMISSIVE_MOVEMENT_ALLOWED.isGo) assertTrue(SignalPhase.STOP_AND_REMAIN.isStop) assertTrue(SignalPhase.PRE_MOVEMENT.isTransition) assertTrue(SignalPhase.PROTECTED_CLEARANCE.isTransition) // dark and unavailable are none of the three - they must not read as "go". assertTrue(!SignalPhase.DARK.isGo && !SignalPhase.DARK.isStop) } @Test fun `countdown handles TimeMark wrapping at the hour boundary`() { val topOfHour = receivedAt - (receivedAt % 3_600_000L) // 400.0 s into the hour, light changes at 409.4 s -> 9.4 s away. val soon = SignalPhaseEvent(SignalPhase.STOP_AND_REMAIN, 4094, null, null) assertEquals(9.4, soon.secondsUntil(topOfHour + 400_000L)!!, 1e-6) // 3590 s into the hour, mark is 10.0 s - that is the NEXT hour, i.e. 20 s away, not // 3580 s in the past. Without the wrap correction a countdown goes hugely negative once // per hour, which is exactly when a driver is watching it. val wrapped = SignalPhaseEvent(SignalPhase.STOP_AND_REMAIN, 100, null, null) assertEquals(20.0, wrapped.secondsUntil(topOfHour + 3_590_000L)!!, 1e-6) // 36001 is the spec's "unknown" marker and must not be shown as a real countdown. val unknown = SignalPhaseEvent(SignalPhase.DARK, 36001, null, null) assertNull(unknown.secondsUntil(topOfHour)) assertNull(SignalPhaseEvent(SignalPhase.DARK, null, null, null).secondsUntil(topOfHour)) } @Test fun `does not decode another message type as SPATEM`() { // protocolVersion 2, messageId 2 (CAM) - the header guard must reject it outright. val cam = byteArrayOf(2, 2, 0, 0, 0, 1, 0, 0, 0, 0) assertNull(SpatemUperCodec.decode(cam, receivedAt)) } @Test fun `returns null for a truncated SPATEM rather than a wrong light`() { val full = liveSpatem.hexToBytes() assertNull(SpatemUperCodec.decode(full.copyOfRange(0, 12), receivedAt)) } }