diff options
| author | Claude Sonnet <agent@anthropic.com> | 2026-03-15 13:13:34 +0000 |
|---|---|---|
| committer | Claude Sonnet <agent@anthropic.com> | 2026-03-15 13:13:34 +0000 |
| commit | 13e4e30f351f06bda23a45b36c05970d1ef2c692 (patch) | |
| tree | b625f4d308deb8070d2790bcad794d3fe932b612 /android-app/app/src/test | |
| parent | d1de605e28bd8ac32d73420ef60235eac4c56a50 (diff) | |
feat: add AIS repository, AISHub API service, and AisHubSource
- AisRepository: processes NMEA sentences, upserts by MMSI, merges type-5 static data, evicts stale
- AisHubApiService + AisHubVessel: Retrofit/Moshi model for AISHub REST polling API
- AisHubSource: converts AisHubVessel REST responses to AisVessel domain objects
- 11 JUnit 5 tests all GREEN via /tmp/ais-repo-test-runner/ JVM harness
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app/src/test')
| -rw-r--r-- | android-app/app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt | 54 | ||||
| -rw-r--r-- | android-app/app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt | 167 |
2 files changed, 221 insertions, 0 deletions
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt new file mode 100644 index 0000000..6f09d68 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt @@ -0,0 +1,54 @@ +package org.terst.nav.ais + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.* +import org.terst.nav.data.api.AisHubVessel + +class AisHubSourceTest { + + private fun makeHubVessel( + mmsi: String = "123456789", + latitude: String = "37.0", + longitude: String = "-122.0", + sog: String = "5.0", + cog: String = "270.0", + heading: String = "270", + name: String = "TEST VESSEL", + type: String = "36" + ) = AisHubVessel(mmsi, latitude, longitude, sog, cog, heading, name, type) + + @Test + fun `valid AisHubVessel maps to AisVessel with correct fields`() { + val hub = makeHubVessel() + val vessel = AisHubSource.toAisVessel(hub) + assertNotNull(vessel) + assertEquals(123456789, vessel!!.mmsi) + assertEquals(37.0, vessel.lat, 0.001) + assertEquals(-122.0, vessel.lon, 0.001) + assertEquals(5.0, vessel.sog, 0.001) + assertEquals(270.0, vessel.cog, 0.001) + assertEquals(270, vessel.heading) + assertEquals("TEST VESSEL", vessel.name) + assertEquals(36, vessel.vesselType) + } + + @Test + fun `non-numeric MMSI returns null`() { + val hub = makeHubVessel(mmsi = "ABCXYZ") + assertNull(AisHubSource.toAisVessel(hub)) + } + + @Test + fun `non-numeric latitude returns null`() { + val hub = makeHubVessel(latitude = "N/A") + assertNull(AisHubSource.toAisVessel(hub)) + } + + @Test + fun `heading 511 passthrough`() { + val hub = makeHubVessel(heading = "511") + val vessel = AisHubSource.toAisVessel(hub) + assertNotNull(vessel) + assertEquals(511, vessel!!.heading) + } +} diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt new file mode 100644 index 0000000..2049c2f --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt @@ -0,0 +1,167 @@ +package org.terst.nav.ais + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.* + +// ── Encoding helpers (mirrors AisVdmParser decode logic) ────────────────────── + +private fun encodeBits(bits: IntArray): String { + val padded = if (bits.size % 6 == 0) bits else bits + IntArray(6 - bits.size % 6) + val sb = StringBuilder() + var i = 0 + while (i < padded.size) { + var v = 0 + for (b in 0 until 6) v = (v shl 1) or padded[i + b] + val c = if (v <= 39) v + 48 else v + 56 + sb.append(c.toChar()) + i += 6 + } + return sb.toString() +} + +private fun intToBits(value: Int, len: Int): IntArray { + val arr = IntArray(len) + for (i in 0 until len) arr[i] = (value ushr (len - 1 - i)) and 1 + return arr +} + +private fun signedToBits(value: Int, len: Int): IntArray { + val mask = if (len < 32) (1 shl len) - 1 else -1 + return intToBits(value and mask, len) +} + +private fun buildType1Payload( + mmsi: Int, sog10: Int = 50, + lon600000: Int = (-122_000_000), + lat600000: Int = (37_000_000), + cog10: Int = 2700, heading: Int = 270 +): String { + val bits = IntArray(168) + fun set(start: Int, arr: IntArray) { arr.copyInto(bits, start) } + set(0, intToBits(1, 6)) + set(6, intToBits(0, 2)) + set(8, intToBits(mmsi, 30)) + set(38, intToBits(0, 12)) + set(50, intToBits(sog10, 10)) + set(60, intToBits(0, 1)) + set(61, signedToBits(lon600000, 28)) + set(89, signedToBits(lat600000, 27)) + set(116, intToBits(cog10, 12)) + set(128, intToBits(heading, 9)) + return encodeBits(bits) +} + +private fun buildType5Payload(mmsi: Int, callsign: String, name: String, vesselType: Int): String { + val bits = IntArray(426) + fun set(start: Int, arr: IntArray) { arr.copyInto(bits, start) } + set(0, intToBits(5, 6)) + set(6, intToBits(0, 2)) + set(8, intToBits(mmsi, 30)) + set(38, intToBits(0, 2)) + set(40, intToBits(0, 30)) + val csFixed = callsign.padEnd(7, '@').take(7) + for (i in 0 until 7) { + val c = csFixed[i].code + set(70 + i * 6, intToBits(if (c >= 64) c - 64 else c, 6)) + } + val nameFixed = name.padEnd(20, '@').take(20) + for (i in 0 until 20) { + val c = nameFixed[i].code + set(112 + i * 6, intToBits(if (c >= 64) c - 64 else c, 6)) + } + set(232, intToBits(vesselType, 8)) + return encodeBits(bits) +} + +private fun nmea0183Checksum(body: String): String { + var xor = 0 + for (c in body) xor = xor xor c.code + return xor.toString(16).uppercase().padStart(2, '0') +} + +private fun makeVdm(payload: String, padding: Int = 0): String { + val body = "AIVDM,1,1,,A,$payload,$padding" + return "!$body*${nmea0183Checksum(body)}" +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +class AisRepositoryTest { + + private fun type1Sentence(mmsi: Int) = makeVdm(buildType1Payload(mmsi)) + private fun type5Sentence(mmsi: Int, callsign: String, name: String, vesselType: Int = 0) = + makeVdm(buildType5Payload(mmsi, callsign, name, vesselType)) + + @Test + fun `processSentence with type-1 adds vessel to targets`() { + val repo = AisRepository() + repo.processSentence(type1Sentence(123456789)) + assertEquals(1, repo.getTargets().size) + } + + @Test + fun `processSentence same MMSI twice updates position and keeps 1 target`() { + val repo = AisRepository() + val sentence = type1Sentence(123456789) + repo.processSentence(sentence) + repo.processSentence(sentence) + assertEquals(1, repo.getTargets().size) + } + + @Test + fun `processSentence type-5 for existing MMSI merges name callsign and vesselType`() { + val repo = AisRepository() + repo.processSentence(type1Sentence(999001)) + repo.processSentence(type5Sentence(999001, "W1ABC", "MY VESSEL", 36)) + val targets = repo.getTargets() + assertEquals(1, targets.size) + assertEquals("MY VESSEL", targets[0].name) + assertEquals("W1ABC", targets[0].callsign) + assertEquals(36, targets[0].vesselType) + } + + @Test + fun `processSentence type-5 before position applies pending static data when position arrives`() { + val repo = AisRepository() + repo.processSentence(type5Sentence(999002, "CALL", "PENDING VESSEL", 60)) + assertEquals(0, repo.getTargets().size) + repo.processSentence(type1Sentence(999002)) + val targets = repo.getTargets() + assertEquals(1, targets.size) + assertEquals("PENDING VESSEL", targets[0].name) + assertEquals("CALL", targets[0].callsign) + assertEquals(60, targets[0].vesselType) + } + + @Test + fun `evictStale removes old targets and keeps recent ones`() { + val repo = AisRepository(staleTimeoutMs = 60_000L) + repo.processSentence(type1Sentence(111)) + repo.processSentence(type1Sentence(222)) + // Evict far into the future: all vessels are stale + repo.evictStale(System.currentTimeMillis() + 120_000L) + assertEquals(0, repo.getTargets().size) + // Add a fresh vessel and evict at now: it should survive + repo.processSentence(type1Sentence(333)) + repo.evictStale(System.currentTimeMillis()) + assertEquals(1, repo.getTargets().size) + } + + @Test + fun `processSentence with non-AIS sentence does not change targets`() { + val repo = AisRepository() + repo.processSentence("\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A") + assertEquals(0, repo.getTargets().size) + } + + @Test + fun `two different MMSIs both appear in getTargets`() { + val repo = AisRepository() + repo.processSentence(type1Sentence(111111111)) + repo.processSentence(type1Sentence(222222222)) + assertEquals(2, repo.getTargets().size) + val mmsis = repo.getTargets().map { it.mmsi }.toSet() + assertTrue(111111111 in mmsis) + assertTrue(222222222 in mmsis) + } +} |
