package com.example.androidapp.routing import com.example.androidapp.data.model.BoatPolars import com.example.androidapp.data.model.WindForecast import org.junit.Assert.* import org.junit.Test class IsochroneRouterTest { private val startTimeMs = 1_000_000_000L private val oneHourMs = 3_600_000L // ── BoatPolars ──────────────────────────────────────────────────────────── @Test fun `bsp returns exact value for exact twa and tws entry`() { val polars = BoatPolars.DEFAULT // At TWS=10, TWA=90 the table has 7.0 kt assertEquals(7.0, polars.bsp(90.0, 10.0), 1e-9) } @Test fun `bsp interpolates between twa entries`() { val polars = BoatPolars.DEFAULT // At TWS=10: TWA=60 → 6.5, TWA=90 → 7.0; midpoint TWA=75 → 6.75 assertEquals(6.75, polars.bsp(75.0, 10.0), 1e-9) } @Test fun `bsp interpolates between tws entries`() { val polars = BoatPolars.DEFAULT // At TWA=90: TWS=10 → 7.0, TWS=15 → 8.0; midpoint TWS=12.5 → 7.5 assertEquals(7.5, polars.bsp(90.0, 12.5), 1e-9) } @Test fun `bsp mirrors port tack twa to starboard`() { val polars = BoatPolars.DEFAULT // TWA=270 should mirror to 360-270=90, giving same as TWA=90 assertEquals(polars.bsp(90.0, 10.0), polars.bsp(270.0, 10.0), 1e-9) } @Test fun `bsp clamps tws below table minimum`() { val polars = BoatPolars.DEFAULT // TWS=0 clamps to minimum TWS=5 assertEquals(polars.bsp(90.0, 5.0), polars.bsp(90.0, 0.0), 1e-9) } @Test fun `bsp clamps tws above table maximum`() { val polars = BoatPolars.DEFAULT // TWS=100 clamps to maximum TWS=20 assertEquals(polars.bsp(90.0, 20.0), polars.bsp(90.0, 100.0), 1e-9) } // ── IsochroneRouter geometry helpers ───────────────────────────────────── @Test fun `haversineM returns zero for same point`() { assertEquals(0.0, IsochroneRouter.haversineM(10.0, 20.0, 10.0, 20.0), 1e-3) } @Test fun `haversineM one degree of latitude is approximately 111_195 m`() { val dist = IsochroneRouter.haversineM(0.0, 0.0, 1.0, 0.0) assertEquals(111_195.0, dist, 50.0) } @Test fun `bearingDeg returns 0 for due north`() { val bearing = IsochroneRouter.bearingDeg(0.0, 0.0, 1.0, 0.0) assertEquals(0.0, bearing, 1e-6) } @Test fun `bearingDeg returns 90 for due east`() { val bearing = IsochroneRouter.bearingDeg(0.0, 0.0, 0.0, 1.0) assertEquals(90.0, bearing, 1e-4) } @Test fun `destinationPoint due north by 1 NM moves latitude by expected amount`() { val (lat, lon) = IsochroneRouter.destinationPoint(0.0, 0.0, 0.0, IsochroneRouter.NM_TO_M) assertTrue("latitude should increase", lat > 0.0) assertEquals(0.0, lon, 1e-9) // 1 NM ≈ 1/60 degree of latitude assertEquals(1.0 / 60.0, lat, 1e-4) } // ── Pruning ─────────────────────────────────────────────────────────────── @Test fun `prune keeps only furthest point per sector`() { // Two points both due north of origin at different distances val close = RoutePoint(1.0, 0.0, startTimeMs) val far = RoutePoint(2.0, 0.0, startTimeMs) val result = IsochroneRouter.prune(listOf(close, far), 0.0, 0.0, 72) assertEquals(1, result.size) assertEquals(far, result[0]) } @Test fun `prune keeps points in different sectors separately`() { // One point north, one point east — different sectors val north = RoutePoint(1.0, 0.0, startTimeMs) val east = RoutePoint(0.0, 1.0, startTimeMs) val result = IsochroneRouter.prune(listOf(north, east), 0.0, 0.0, 72) assertEquals(2, result.size) } // ── Full routing ────────────────────────────────────────────────────────── @Test fun `route finds path to destination with constant wind`() { // Destination is ~5 NM due east of start; constant 10kt easterly (FROM east = 90°) // A 10kt boat sailing downwind (TWA=180) = 6.0 kt; ~5 NM / 6 kt ≈ 50 min → 1 step val destLat = 0.0 val destLon = 0.0 + (5.0 / 60.0) // ~5 NM east val constantWind = { _: Double, _: Double, _: Long -> WindForecast(0.0, 0.0, startTimeMs, twsKt = 10.0, twdDeg = 90.0) } val result = IsochroneRouter.route( startLat = 0.0, startLon = 0.0, destLat = destLat, destLon = destLon, startTimeMs = startTimeMs, stepMs = oneHourMs, polars = BoatPolars.DEFAULT, windAt = constantWind, arrivalRadiusM = 2_000.0 // 2 km arrival radius ) assertNotNull("Should find a route", result) result!! assertTrue("Path should have at least 2 points (start + arrival)", result.path.size >= 2) assertEquals("Path should start at origin", 0.0, result.path.first().lat, 1e-6) assertEquals("ETA should be after start", startTimeMs, result.etaMs - oneHourMs) } @Test fun `route returns null when polars produce zero speed`() { val zeroPolar = BoatPolars(emptyMap()) val result = IsochroneRouter.route( startLat = 0.0, startLon = 0.0, destLat = 1.0, destLon = 0.0, startTimeMs = startTimeMs, stepMs = oneHourMs, polars = zeroPolar, windAt = { _, _, _ -> WindForecast(0.0, 0.0, startTimeMs, 10.0, 0.0) }, maxSteps = 3 ) assertNull("Should return null when no progress is possible", result) } @Test fun `backtrace returns path from start to arrival in order`() { val p0 = RoutePoint(0.0, 0.0, 0L) val p1 = RoutePoint(1.0, 0.0, 1L, parent = p0) val p2 = RoutePoint(2.0, 0.0, 2L, parent = p1) val path = IsochroneRouter.backtrace(p2) assertEquals(3, path.size) assertEquals(p0, path[0]) assertEquals(p1, path[1]) assertEquals(p2, path[2]) } }