diff options
| author | Claude <noreply@anthropic.com> | 2026-04-22 08:45:55 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-04-22 08:45:55 +0000 |
| commit | 87f9cb753da3325d13fdbc442d526567e056d6e1 (patch) | |
| tree | fcad4c91a817b3ce623abbaf5b12dab875d08aa3 /test-runner/src/test | |
| parent | 447c274999b6b412985b640f841ab92d8c23c0f8 (diff) | |
feat: CPA/TCPA collision alerts and live track stats
CPA/TCPA alerts:
- CpaAlert data class with severity (CAUTION/WARNING/DANGER) and label()
- CpaThresholds: 0.5/1.0/2.0 nm, max TCPA 30 min
- MainViewModel.updateCpaAlerts() runs on every GPS update and AIS
sentence; skips stationary contacts; filters TCPA ≤ 0 or > 30 min;
sorts by TCPA ascending
- SafetyFragment: new COLLISION AVOIDANCE card shows live alert list,
color-coded by severity; clears to "No traffic alerts" when safe
- MainActivity: observes cpaAlerts → safetyFragment.updateCpaAlerts()
Track stats:
- TrackStats data class: distanceNm, durationMs, avgSogKnots,
durationFormatted (m:ss / h:mm:ss)
- TrackRepository.haversineNm() + computeStats() using point timestamps
- MainViewModel exposes trackStats StateFlow, cleared on stopTrack()
- activity_main: dark pill overlay bar (tv_track_stats) above map
- MainActivity: shows "● REC 0.00nm m:ss x.xkt avg" while recording
Tests: 95 total, all GREEN (+16 new — 9 TrackStats, 7 CpaAlert)
https://claude.ai/code/session_011h2dXbgXg3PesQMmQUNTCW
Diffstat (limited to 'test-runner/src/test')
| -rw-r--r-- | test-runner/src/test/kotlin/org/terst/nav/ais/CpaAlertTest.kt | 78 | ||||
| -rw-r--r-- | test-runner/src/test/kotlin/org/terst/nav/track/TrackStatsTest.kt | 79 |
2 files changed, 157 insertions, 0 deletions
diff --git a/test-runner/src/test/kotlin/org/terst/nav/ais/CpaAlertTest.kt b/test-runner/src/test/kotlin/org/terst/nav/ais/CpaAlertTest.kt new file mode 100644 index 0000000..3effb24 --- /dev/null +++ b/test-runner/src/test/kotlin/org/terst/nav/ais/CpaAlertTest.kt @@ -0,0 +1,78 @@ +package org.terst.nav.ais + +import org.junit.Assert.* +import org.junit.Test + +class CpaAlertTest { + + // --- CpaCalculator --- + + @Test + fun `head-on convergence produces small CPA and positive TCPA`() { + // Own vessel heading north at 10 kt; target heading south at 10 kt, 1 nm ahead + val (cpa, tcpa) = CpaCalculator.compute( + ownLat = 0.0, ownLon = 0.0, ownSog = 10.0, ownCog = 0.0, + tgtLat = 1.0 / 60.0, tgtLon = 0.0, tgtSog = 10.0, tgtCog = 180.0 + ) + assertTrue("CPA should be near zero, got $cpa", cpa < 0.1) + assertTrue("TCPA should be positive, got $tcpa", tcpa > 0) + assertTrue("TCPA should be < 5 min, got $tcpa", tcpa < 5.0) + } + + @Test + fun `diverging vessels produce negative TCPA`() { + // Own heading north, target heading north ahead — both going same direction with target faster + val (_, tcpa) = CpaCalculator.compute( + ownLat = 0.0, ownLon = 0.0, ownSog = 5.0, ownCog = 0.0, + tgtLat = 1.0 / 60.0, tgtLon = 0.0, tgtSog = 10.0, tgtCog = 0.0 + ) + assertTrue("TCPA should be negative for diverging vessels, got $tcpa", tcpa < 0) + } + + @Test + fun `zero relative velocity returns current distance`() { + // Same velocity — parallel course, same speed + val (cpa, tcpa) = CpaCalculator.compute( + ownLat = 0.0, ownLon = 0.0, ownSog = 5.0, ownCog = 90.0, + tgtLat = 1.0 / 60.0, tgtLon = 0.0, tgtSog = 5.0, tgtCog = 90.0 + ) + assertTrue("CPA should equal current distance, got $cpa", cpa > 0.9 && cpa < 1.1) + assertEquals(0.0, tcpa, 1e-9) + } + + @Test + fun `well-separated crossing vessels produce large CPA`() { + // Perpendicular courses but far apart — should not alert + val (cpa, _) = CpaCalculator.compute( + ownLat = 0.0, ownLon = 0.0, ownSog = 8.0, ownCog = 0.0, + tgtLat = 0.0, tgtLon = 5.0, tgtSog = 8.0, tgtCog = 270.0 + ) + assertTrue("CPA should be > 2 nm for well-separated vessels, got $cpa", cpa > 2.0) + } + + // --- Severity classification --- + + @Test + fun `DANGER severity for CPA under 0_5 nm`() { + val alert = CpaAlert(123, "TARGET", 0.3, 10.0, CpaSeverity.DANGER) + assertEquals(CpaSeverity.DANGER, alert.severity) + } + + @Test + fun `label formats correctly with vessel name`() { + val alert = CpaAlert(123456789, "STENA IMPERO", 0.8, 12.5, CpaSeverity.WARNING) + val lbl = alert.label() + assertTrue(lbl.contains("0.8")) + assertTrue(lbl.contains("13") || lbl.contains("12")) // rounded min + assertTrue(lbl.contains("STENA")) + } + + @Test + fun `label uses MMSI prefix when name is blank`() { + val alert = CpaAlert(987654321, "", 1.5, 20.0, CpaSeverity.CAUTION) + // "MMSI 987654321" is 14 chars; label() takes the first 12 -> "MMSI 9876543" + val lbl = alert.label() + assertTrue("Expected MMSI prefix in label, got: $lbl", lbl.contains("MMSI")) + assertTrue("Expected partial MMSI in label, got: $lbl", lbl.contains("987654")) + } +} diff --git a/test-runner/src/test/kotlin/org/terst/nav/track/TrackStatsTest.kt b/test-runner/src/test/kotlin/org/terst/nav/track/TrackStatsTest.kt new file mode 100644 index 0000000..7bce0a2 --- /dev/null +++ b/test-runner/src/test/kotlin/org/terst/nav/track/TrackStatsTest.kt @@ -0,0 +1,79 @@ +package org.terst.nav.track + +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class TrackStatsTest { + + private lateinit var repo: TrackRepository + + private fun pt(lat: Double, lon: Double, sog: Double = 5.0, ts: Long = 0L) = + TrackPoint(lat, lon, sog, 0.0, 0.0, 0.0, false, ts) + + @Before + fun setup() { + repo = TrackRepository() + repo.startTrack() + } + + @Test + fun `computeStats returns null for single point`() { + repo.addPoint(pt(41.0, -71.0, ts = 0)) + assertNull(repo.computeStats()) + } + + @Test + fun `computeStats returns null when not recording and no points`() { + repo.stopTrack() + assertNull(repo.computeStats()) + } + + @Test + fun `haversineNm one degree of latitude is approximately 60 nm`() { + val d = TrackRepository.haversineNm(0.0, 0.0, 1.0, 0.0) + assertEquals(60.0, d, 0.5) + } + + @Test + fun `haversineNm zero distance returns zero`() { + assertEquals(0.0, TrackRepository.haversineNm(41.0, -71.0, 41.0, -71.0), 1e-9) + } + + @Test + fun `computeStats distance accumulates across legs`() { + repo.addPoint(pt(0.0, 0.0, ts = 0)) + repo.addPoint(pt(1.0, 0.0, ts = 3_600_000)) // ~60 nm north + repo.addPoint(pt(1.0, 1.0, ts = 7_200_000)) // ~60 nm east at 1° lat + val stats = repo.computeStats()!! + assertTrue("Expected ~120 nm, got ${stats.distanceNm}", stats.distanceNm > 100.0) + } + + @Test + fun `computeStats duration is last minus first timestamp`() { + repo.addPoint(pt(0.0, 0.0, ts = 1_000)) + repo.addPoint(pt(1.0, 0.0, ts = 3_601_000)) + val stats = repo.computeStats()!! + assertEquals(3_600_000L, stats.durationMs) + } + + @Test + fun `computeStats avgSog is mean of all point sogs`() { + repo.addPoint(pt(0.0, 0.0, sog = 4.0, ts = 0)) + repo.addPoint(pt(0.0, 0.1, sog = 6.0, ts = 1000)) + val stats = repo.computeStats()!! + assertEquals(5.0, stats.avgSogKnots, 0.001) + } + + @Test + fun `durationFormatted shows m_ss below one hour`() { + val stats = TrackStats(1.0, 65_000L, 5.0) // 1 min 5 sec + assertEquals("1:05", stats.durationFormatted) + } + + @Test + fun `durationFormatted shows h_mm_ss above one hour`() { + val stats = TrackStats(1.0, 3_661_000L, 5.0) // 1h 1m 1s + assertEquals("1:01:01", stats.durationFormatted) + } +} |
