Files
MicrOBU/app/src/test/java/com/hawhamburg/micr0bu/SpatemUperCodecTest.kt
T

169 lines
7.6 KiB
Kotlin
Raw Normal View History

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))
}
}