diff options
Diffstat (limited to 'android-app/app/src/test')
8 files changed, 795 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) + } +} diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ais/AisVesselTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisVesselTest.kt new file mode 100644 index 0000000..a34a733 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisVesselTest.kt @@ -0,0 +1,53 @@ +package org.terst.nav.ais + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class AisVesselTest { + + @Test + fun `holds all fields correctly`() { + val vessel = AisVessel( + mmsi = 123456789, + name = "MY VESSEL", + callsign = "W1ABC", + lat = 37.5, + lon = -122.0, + sog = 5.5, + cog = 270.0, + heading = 269, + vesselType = 36, + timestampMs = 1000L + ) + assertEquals(123456789, vessel.mmsi) + assertEquals("MY VESSEL", vessel.name) + assertEquals("W1ABC", vessel.callsign) + assertEquals(37.5, vessel.lat) + assertEquals(-122.0, vessel.lon) + assertEquals(5.5, vessel.sog) + assertEquals(270.0, vessel.cog) + assertEquals(269, vessel.heading) + assertEquals(36, vessel.vesselType) + assertEquals(1000L, vessel.timestampMs) + } + + @Test + fun `equality based on all fields`() { + val v1 = AisVessel(1, "A", "B", 0.0, 0.0, 0.0, 0.0, 0, 0, 100L) + val v2 = AisVessel(1, "A", "B", 0.0, 0.0, 0.0, 0.0, 0, 0, 100L) + assertEquals(v1, v2) + } + + @Test + fun `inequality when mmsi differs`() { + val v1 = AisVessel(1, "A", "B", 0.0, 0.0, 0.0, 0.0, 0, 0, 100L) + val v2 = AisVessel(2, "A", "B", 0.0, 0.0, 0.0, 0.0, 0, 0, 100L) + assertNotEquals(v1, v2) + } + + @Test + fun `heading 511 means not available`() { + val vessel = AisVessel(1, "", "", 0.0, 0.0, 0.0, 0.0, 511, 0, 0L) + assertEquals(511, vessel.heading) + } +}
\ No newline at end of file diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ais/CpaCalculatorTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ais/CpaCalculatorTest.kt new file mode 100644 index 0000000..38069ec --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/ais/CpaCalculatorTest.kt @@ -0,0 +1,61 @@ +package org.terst.nav.ais + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.math.abs + +class CpaCalculatorTest { + + private val eps = 0.01 // 0.01 nm tolerance for position, 0.01 min for time + + @Test + fun `head-on vessels converging - TCPA around 0_25 min CPA near zero`() { + // Own: (0,0) moving North at 10kt + // Target: (0, 0.0833 nm N ~= 0.0833/60 deg lat) moving South at 10kt + // They meet in the middle ~= 0.25 min + val tgtLat = 0.0833 / 60.0 // 0.0833 nm north in degrees + val (cpa, tcpa) = CpaCalculator.compute( + ownLat = 0.0, ownLon = 0.0, ownSog = 10.0, ownCog = 0.0, + tgtLat = tgtLat, tgtLon = 0.0, tgtSog = 10.0, tgtCog = 180.0 + ) + assertTrue(tcpa > 0.0, "TCPA should be positive (converging): $tcpa") + assertTrue(abs(tcpa - 0.25) < eps, "TCPA should be ~0.25 min, got $tcpa") + assertTrue(cpa < 0.01, "CPA should be near zero for head-on, got $cpa") + } + + @Test + fun `diverging vessels - same direction target ahead - TCPA negative or CPA large`() { + // Own: (0,0) moving North at 5kt + // Target: (0, 1nm N) moving North at 10kt (pulling away) + val tgtLat = 1.0 / 60.0 // 1 nm north + val (cpa, tcpa) = CpaCalculator.compute( + ownLat = 0.0, ownLon = 0.0, ownSog = 5.0, ownCog = 0.0, + tgtLat = tgtLat, tgtLon = 0.0, tgtSog = 10.0, tgtCog = 0.0 + ) + // Target is faster and ahead — diverging, TCPA should be negative + assertTrue(tcpa < 0.0, "TCPA should be negative (diverging), got $tcpa") + } + + @Test + fun `zero relative velocity returns current distance and TCPA zero`() { + // Same speed, same course — relative velocity zero + val tgtLat = 1.0 / 60.0 // 1 nm north + val (cpa, tcpa) = CpaCalculator.compute( + ownLat = 0.0, ownLon = 0.0, ownSog = 5.0, ownCog = 90.0, + tgtLat = tgtLat, tgtLon = 0.0, tgtSog = 5.0, tgtCog = 90.0 + ) + assertEquals(0.0, tcpa, 1e-9) + // Current distance should be ~1 nm + assertTrue(abs(cpa - 1.0) < 0.01, "CPA should be ~1 nm (current dist), got $cpa") + } + + @Test + fun `both at same position zero relative velocity - zero distance`() { + val (cpa, tcpa) = CpaCalculator.compute( + ownLat = 37.0, ownLon = -122.0, ownSog = 5.0, ownCog = 0.0, + tgtLat = 37.0, tgtLon = -122.0, tgtSog = 5.0, tgtCog = 0.0 + ) + assertEquals(0.0, tcpa, 1e-9) + assertEquals(0.0, cpa, 1e-6) + } +}
\ No newline at end of file diff --git a/android-app/app/src/test/kotlin/org/terst/nav/nmea/AisVdmParserTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/nmea/AisVdmParserTest.kt new file mode 100644 index 0000000..da3efd3 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/nmea/AisVdmParserTest.kt @@ -0,0 +1,205 @@ +package org.terst.nav.nmea + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.math.abs + +/** + * Encoding helpers to build synthetic AIS payloads for round-trip testing. + * Mirrors the decoder logic exactly. + */ +private fun encodeBits(bits: IntArray): String { + // Pad to multiple of 6 + 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 { + // Two's complement + val mask = if (len < 32) (1 shl len) - 1 else -1 + return intToBits(value and mask, len) +} + +/** Build a type-1 message bit array (168 bits) */ +private fun buildType1Payload( + mmsi: Int, sog10: Int, lon600000: Int, lat600000: Int, cog10: Int, heading: Int +): String { + val bits = IntArray(168) + fun set(start: Int, arr: IntArray) { arr.copyInto(bits, start) } + set(0, intToBits(1, 6)) // message type = 1 + set(6, intToBits(0, 2)) // repeat + set(8, intToBits(mmsi, 30)) // MMSI + set(38, intToBits(0, 12)) // nav status + rot (12 bits filler) + set(50, intToBits(sog10, 10)) // SOG * 10 + set(60, intToBits(0, 1)) // accuracy + set(61, signedToBits(lon600000, 28)) // lon * 600000 + set(89, signedToBits(lat600000, 27)) // lat * 600000 + set(116, intToBits(cog10, 12)) // COG * 10 + set(128, intToBits(heading, 9)) // heading + // remaining bits 137-167 = 0 (timestamp, maneuver, spare, raim, radio) + return encodeBits(bits) +} + +/** Build a type-5 message bit array (426 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)) // message type = 5 + set(6, intToBits(0, 2)) // repeat + set(8, intToBits(mmsi, 30)) // MMSI + set(38, intToBits(0, 2)) // ais version + set(40, intToBits(0, 30)) // IMO number + // AIS 6-bit text encode: chars >=64 (A-Z,@) -> c-64 (gives 0-31); else c (space=32, digits=48-57) + val csFixed = callsign.padEnd(7, '@').take(7) + for (i in 0 until 7) { + val c = csFixed[i].code + val v = if (c >= 64) c - 64 else c + set(70 + i * 6, intToBits(v, 6)) + } + // name: bits 112-231 = 20 chars x 6 bits + val nameFixed = name.padEnd(20, '@').take(20) + for (i in 0 until 20) { + val c = nameFixed[i].code + val v = if (c >= 64) c - 64 else c + set(112 + i * 6, intToBits(v, 6)) + } + // vessel type: bits 232-239 + set(232, intToBits(vesselType, 8)) + // rest = 0 (draught, destination, dte, spare) + 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)}" +} + +private fun makeVdmPart(count: Int, seq: Int, seqId: String, payload: String, padding: Int = 0): String { + val body = "AIVDM,$count,$seq,$seqId,A,$payload,$padding" + return "!$body*${nmea0183Checksum(body)}" +} + +class AisVdmParserTest { + + private val parser = AisVdmParser() + + @Test + fun `non-AIS NMEA sentence returns null`() { + assertNull(parser.parse("\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A")) + } + + @Test + fun `malformed sentence returns null`() { + assertNull(parser.parse("garbage")) + assertNull(parser.parse("!AIVDM,1,1,,A,")) + assertNull(parser.parse("")) + } + + @Test + fun `type-1 round-trip - known MMSI lat lon sog cog heading`() { + // MMSI=123456789, sog=5.0kt (50), lon=-122.0deg, lat=37.0deg, cog=270.0 (2700), heading=270 + val mmsi = 123456789 + val sog10 = 50 // 5.0 knots + val lon600000 = (-122.0 * 600000.0).toInt() // -73200000 + val lat600000 = (37.0 * 600000.0).toInt() // 22200000 + val cog10 = 2700 // 270.0 degrees + val heading = 270 + + val payload = buildType1Payload(mmsi, sog10, lon600000, lat600000, cog10, heading) + val sentence = makeVdm(payload) + val vessel = parser.parse(sentence) + + assertNotNull(vessel) + assertEquals(mmsi, vessel!!.mmsi) + assertEquals(5.0, vessel.sog, 0.01) + assertEquals(-122.0, vessel.lon, 0.001) + assertEquals(37.0, vessel.lat, 0.001) + assertEquals(270.0, vessel.cog, 0.1) + assertEquals(270, vessel.heading) + assertEquals("", vessel.name) + assertEquals("", vessel.callsign) + assertEquals(0, vessel.vesselType) + } + + @Test + fun `type-1 real sentence - MMSI 227006760`() { + val sentence = "!AIVDM,1,1,,A,13HOI:0P0000vocH;`5HF>0<0000,0*54" + val vessel = parser.parse(sentence) + assertNotNull(vessel) + assertEquals(227006760, vessel!!.mmsi) + } + + @Test + fun `type-5 round-trip - name and callsign decoded and trimmed`() { + val mmsi = 987654321 + val payload = buildType5Payload(mmsi, "W1ABC", "MY VESSEL NAME", vesselType = 36) + val sentence = makeVdm(payload) + val vessel = parser.parse(sentence) + + assertNotNull(vessel) + assertEquals(mmsi, vessel!!.mmsi) + assertEquals("W1ABC", vessel.callsign) + assertEquals("MY VESSEL NAME", vessel.name) + assertEquals(36, vessel.vesselType) + assertEquals(0.0, vessel.lat) + assertEquals(0.0, vessel.lon) + assertEquals(511, vessel.heading) + } + + @Test + fun `two-part message reassembly`() { + // Build a type-1 payload, split it in half across two sentences + val mmsi = 111222333 + val payload = buildType1Payload(mmsi, 100, 0, 0, 900, 511) + val half = payload.length / 2 + val part1 = payload.substring(0, half) + val part2 = payload.substring(half) + + val s1 = makeVdmPart(2, 1, "1", part1, 0) + val s2 = makeVdmPart(2, 2, "1", part2, 0) + + // First sentence: incomplete, returns null + assertNull(parser.parse(s1)) + // Second sentence: completes reassembly + val vessel = parser.parse(s2) + assertNotNull(vessel) + assertEquals(mmsi, vessel!!.mmsi) + } + + @Test + fun `SOG not available value 1023 decodes to 0_0`() { + val payload = buildType1Payload(111000111, 1023, 0, 0, 0, 511) + val vessel = parser.parse(makeVdm(payload)) + assertNotNull(vessel) + assertEquals(0.0, vessel!!.sog) + } + + @Test + fun `COG not available value 3600 decodes to 0_0`() { + val payload = buildType1Payload(111000222, 0, 0, 0, 3600, 511) + val vessel = parser.parse(makeVdm(payload)) + assertNotNull(vessel) + assertEquals(0.0, vessel!!.cog) + } +}
\ No newline at end of file diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelTest.kt index edecdd5..0f5cefe 100644 --- a/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelTest.kt +++ b/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelTest.kt @@ -1,6 +1,7 @@ package org.terst.nav.ui import app.cash.turbine.test +import org.terst.nav.ais.AisVessel import org.terst.nav.data.model.ForecastItem import org.terst.nav.data.model.WindArrow import org.terst.nav.data.repository.WeatherRepository @@ -102,4 +103,43 @@ class MainViewModelTest { cancelAndIgnoreRemainingEvents() } } + + // ── AIS integration tests ──────────────────────────────────────────────── + + @Test + fun `processAisSentence valid type-1 NMEA adds 1 vessel to aisTargets`() { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast) + vm = makeVm() + + // Known real type-1 sentence; MMSI = 227006760 + vm.processAisSentence("!AIVDM,1,1,,A,13HOI:0P0000vocH;`5HF>0<0000,0*54") + + assertEquals(1, vm.aisTargets.value.size) + assertEquals(227006760, vm.aisTargets.value[0].mmsi) + } + + @Test + fun `processAisSentence same MMSI twice keeps exactly 1 vessel in aisTargets`() { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast) + vm = makeVm() + + val sentence = "!AIVDM,1,1,,A,13HOI:0P0000vocH;`5HF>0<0000,0*54" + vm.processAisSentence(sentence) + vm.processAisSentence(sentence) + + assertEquals(1, vm.aisTargets.value.size) + } + + @Test + fun `processAisSentence non-AIS sentence leaves aisTargets empty`() { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast) + vm = makeVm() + + vm.processAisSentence("\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A") + + assertEquals(0, vm.aisTargets.value.size) + } } diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt new file mode 100644 index 0000000..9caa5a0 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt @@ -0,0 +1,110 @@ +package org.terst.nav.ui + +import org.junit.Assert.* +import org.junit.Test + +class LocationPermissionHandlerTest { + + // Convenience factory — callers override only the lambdas they care about. + private fun makeHandler( + checkGranted: () -> Boolean = { false }, + onGranted: () -> Unit = {}, + onDenied: () -> Unit = {}, + requestPermissions: () -> Unit = {} + ) = LocationPermissionHandler(checkGranted, onGranted, onDenied, requestPermissions) + + // ── start() ────────────────────────────────────────────────────────────── + + @Test + fun `start - permission already granted - calls onGranted without requesting`() { + var onGrantedCalled = false + var requestCalled = false + makeHandler( + checkGranted = { true }, + onGranted = { onGrantedCalled = true }, + requestPermissions = { requestCalled = true } + ).start() + + assertTrue("onGranted should be called", onGrantedCalled) + assertFalse("requestPermissions should NOT be called", requestCalled) + } + + @Test + fun `start - permission not granted - calls requestPermissions without calling onGranted`() { + var onGrantedCalled = false + var requestCalled = false + makeHandler( + checkGranted = { false }, + onGranted = { onGrantedCalled = true }, + requestPermissions = { requestCalled = true } + ).start() + + assertFalse("onGranted should NOT be called", onGrantedCalled) + assertTrue("requestPermissions should be called", requestCalled) + } + + // ── onResult() ─────────────────────────────────────────────────────────── + + @Test + fun `onResult - fine location granted - calls onGranted`() { + var onGrantedCalled = false + makeHandler(onGranted = { onGrantedCalled = true }).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to true, + "android.permission.ACCESS_COARSE_LOCATION" to false + ) + ) + assertTrue("onGranted should be called when fine location is granted", onGrantedCalled) + } + + @Test + fun `onResult - coarse location granted - calls onGranted`() { + var onGrantedCalled = false + makeHandler(onGranted = { onGrantedCalled = true }).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to false, + "android.permission.ACCESS_COARSE_LOCATION" to true + ) + ) + assertTrue("onGranted should be called when coarse location is granted", onGrantedCalled) + } + + @Test + fun `onResult - both permissions granted - calls onGranted`() { + var onGrantedCalled = false + makeHandler(onGranted = { onGrantedCalled = true }).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to true, + "android.permission.ACCESS_COARSE_LOCATION" to true + ) + ) + assertTrue(onGrantedCalled) + } + + @Test + fun `onResult - all permissions denied - calls onDenied not onGranted`() { + var onGrantedCalled = false + var onDeniedCalled = false + makeHandler( + onGranted = { onGrantedCalled = true }, + onDenied = { onDeniedCalled = true } + ).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to false, + "android.permission.ACCESS_COARSE_LOCATION" to false + ) + ) + assertFalse("onGranted should NOT be called", onGrantedCalled) + assertTrue("onDenied should be called", onDeniedCalled) + } + + @Test + fun `onResult - empty grants (never ask again scenario) - calls onDenied`() { + var onDeniedCalled = false + makeHandler(onDenied = { onDeniedCalled = true }).onResult(emptyMap()) + assertTrue( + "onDenied should be called for empty grants (never-ask-again)", + onDeniedCalled + ) + } +} diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt new file mode 100644 index 0000000..edecdd5 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt @@ -0,0 +1,105 @@ +package org.terst.nav.ui + +import app.cash.turbine.test +import org.terst.nav.data.model.ForecastItem +import org.terst.nav.data.model.WindArrow +import org.terst.nav.data.repository.WeatherRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MainViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val repo = mockk<WeatherRepository>() + private lateinit var vm: MainViewModel + + private val sampleArrow = WindArrow(37.5, -122.3, 15.0, 270.0) + private val sampleForecast = listOf( + ForecastItem("2026-03-13T00:00", 15.0, 270.0, 18.5, 20, 1) + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun makeVm() = MainViewModel(repo) + + @Test + fun `initial uiState is Loading`() { + coEvery { repo.fetchWindArrow(any(), any()) } coAnswers { Result.success(sampleArrow) } + coEvery { repo.fetchForecastItems(any(), any()) } coAnswers { Result.success(sampleForecast) } + vm = makeVm() + // Before loadWeather() is called the state is Loading + assertEquals(UiState.Loading, vm.uiState.value) + } + + @Test + fun `loadWeather success transitions to Success state`() = runTest { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast) + vm = makeVm() + + vm.uiState.test { + assertEquals(UiState.Loading, awaitItem()) + vm.loadWeather(37.5, -122.3) + assertEquals(UiState.Success, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `loadWeather populates windArrow and forecast`() = runTest { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast) + vm = makeVm() + vm.loadWeather(37.5, -122.3) + + assertEquals(sampleArrow, vm.windArrow.value) + assertEquals(sampleForecast, vm.forecast.value) + } + + @Test + fun `loadWeather arrow failure transitions to Error state`() = runTest { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.failure(RuntimeException("Net error")) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast) + vm = makeVm() + + vm.uiState.test { + awaitItem() // Loading + vm.loadWeather(37.5, -122.3) + val state = awaitItem() + assertTrue(state is UiState.Error) + assertTrue((state as UiState.Error).message.contains("Net error")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `loadWeather forecast failure transitions to Error state`() = runTest { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.failure(RuntimeException("Timeout")) + vm = makeVm() + + vm.uiState.test { + awaitItem() // Loading + vm.loadWeather(37.5, -122.3) + val state = awaitItem() + assertTrue(state is UiState.Error) + cancelAndIgnoreRemainingEvents() + } + } +} |
