120 lines
4.8 KiB
Kotlin
120 lines
4.8 KiB
Kotlin
package com.hawhamburg.micr0bu
|
|||
|
|
|
||
|
|
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||
|
|
import org.junit.Assert.assertEquals
|
||
|
|
import org.junit.Assert.assertFalse
|
||
|
|
import org.junit.Assert.assertNotNull
|
||
|
|
import org.junit.Assert.assertNull
|
||
|
|
import org.junit.Assert.assertTrue
|
||
|
|
import org.junit.Test
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Tests [DenmParser] against the CiT One Use Case API's documented DENM schema.
|
||
|
|
*
|
||
|
|
* The payload below is the worked example from `CI-CiT-MQTT_API_Documentation-v6-20250221.pdf`,
|
||
|
|
* listing 2.6 (section 2.2.4, "Processed DENM"), reproduced field-for-field. That document is the
|
||
|
|
* contract for this topic, so it is the right thing to pin against - the previous parser was
|
||
|
|
* written before the schema was checked and silently dropped every real DENM for two independent
|
||
|
|
* reasons: the station id is `originatingStationId` (not `stationId`), and `eventPosition` is
|
||
|
|
* itself a GeoJSON Point rather than an object containing one.
|
||
|
|
*/
|
||
|
|
class DenmParserMqttTest {
|
||
|
|
|
||
|
|
/** Listing 2.6 from the API documentation, with the doc's inline comments removed. */
|
||
|
|
private val documentedDenm = """
|
||
|
|
{
|
||
|
|
"type": "v2x-denm",
|
||
|
|
"originatingStationId": 1345267,
|
||
|
|
"sequenceNumber": 1,
|
||
|
|
"detectionTime": "2021-05-11T12:01:02+00:00",
|
||
|
|
"referenceTime": "2021-05-11T12:01:02+00:00",
|
||
|
|
"eventPosition": { "type": "Point", "coordinates": [9.9800230, 53.5560783, 15] },
|
||
|
|
"relevanceTrafficDirection": "upstreamTraffic",
|
||
|
|
"stationType": "roadSideUnit",
|
||
|
|
"causeCode": "trafficCondition",
|
||
|
|
"subCauseCode": 0
|
||
|
|
}
|
||
|
|
""".trimIndent()
|
||
|
|
|
||
|
|
@Test
|
||
|
|
fun `parses the documented DENM payload`() {
|
||
|
|
val denm = DenmParser.parse(documentedDenm, timestamp = 1_787_000_000_000L)
|
||
|
|
assertNotNull("the API's own documented payload must parse", denm)
|
||
|
|
denm!!
|
||
|
|
|
||
|
|
assertEquals(1_345_267L, denm.stationId)
|
||
|
|
assertEquals(1, denm.sequenceNumber)
|
||
|
|
|
||
|
|
// GeoJSON is [longitude, latitude, altitude] - getting this order wrong puts a Hamburg
|
||
|
|
// hazard in Somalia, and both values are plausible-looking numbers either way.
|
||
|
|
assertEquals(53.5560783, denm.latitude, 1e-7)
|
||
|
|
assertEquals(9.9800230, denm.longitude, 1e-7)
|
||
|
|
|
||
|
|
assertEquals(1, denm.causeCode) // trafficCondition
|
||
|
|
assertEquals(0, denm.subCauseCode)
|
||
|
|
assertEquals(15, denm.stationType) // roadSideUnit is 15, not 12 - the enum has a gap
|
||
|
|
assertFalse(denm.isTermination)
|
||
|
|
|
||
|
|
// 2021-05-11T12:01:02Z
|
||
|
|
assertEquals(1_620_734_462_000L, denm.detectionTimeMs)
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
fun `termination is signalled by the key being present`() {
|
||
|
|
val terminated = documentedDenm.replace(
|
||
|
|
"\"sequenceNumber\": 1,",
|
||
|
|
"\"sequenceNumber\": 1,\n \"termination\": true,",
|
||
|
|
)
|
||
|
|
val denm = DenmParser.parse(terminated)
|
||
|
|
assertNotNull(denm)
|
||
|
|
assertTrue(denm!!.isTermination)
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
fun `a termination shares the dedup key of the event it ends`() {
|
||
|
|
val active = DenmParser.parse(documentedDenm)!!
|
||
|
|
val terminated = DenmParser.parse(
|
||
|
|
documentedDenm.replace(
|
||
|
|
"\"sequenceNumber\": 1,",
|
||
|
|
"\"sequenceNumber\": 1,\n \"termination\": true,",
|
||
|
|
)
|
||
|
|
)!!
|
||
|
|
// Without this, a cancelled hazard would be filtered out while the active pin it was
|
||
|
|
// meant to cancel stayed on the map forever.
|
||
|
|
assertEquals(active.dedupKey, terminated.dedupKey)
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
fun `cause code names follow the ETSI spelling the API uses`() {
|
||
|
|
// ETSI's CauseCodeType really does spell it with three n's, and the API follows.
|
||
|
|
val aqua = documentedDenm.replace("\"trafficCondition\"", "\"aquaplannning\"")
|
||
|
|
assertEquals(7, DenmParser.parse(aqua)!!.causeCode)
|
||
|
|
|
||
|
|
val stationary = documentedDenm.replace("\"trafficCondition\"", "\"stationaryVehicle\"")
|
||
|
|
assertEquals(94, DenmParser.parse(stationary)!!.causeCode)
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
fun `a payload with no usable position is rejected rather than placed at null island`() {
|
||
|
|
val noPosition = documentedDenm.replace(
|
||
|
|
"\"eventPosition\": { \"type\": \"Point\", \"coordinates\": [9.9800230, 53.5560783, 15] },",
|
||
|
|
"",
|
||
|
|
)
|
||
|
|
assertNull(DenmParser.parse(noPosition))
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
fun `integer causeCode and stationId spellings still parse`() {
|
||
|
|
// The air path and any future firmware-side JSON produce integers; those must keep working.
|
||
|
|
val numeric = """
|
||
|
|
{"stationId": 42, "causeCode": 94, "subCauseCode": 1,
|
||
|
|
"eventPosition": {"type": "Point", "coordinates": [10.0, 53.5]}}
|
||
|
|
""".trimIndent()
|
||
|
|
val denm = DenmParser.parse(numeric)
|
||
|
|
assertNotNull(denm)
|
||
|
|
assertEquals(42L, denm!!.stationId)
|
||
|
|
assertEquals(94, denm.causeCode)
|
||
|
|
assertEquals(1, denm.subCauseCode)
|
||
|
|
}
|
||
|
|
}
|