summaryrefslogtreecommitdiff
path: root/android-app/app/src/test/kotlin/com
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-04-04 07:45:41 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-04-04 07:45:41 +0000
commit97715ab4007ff3101f58edf4385cef1fc3d1615b (patch)
tree464bdb1df8cfed31402f5316fe84df974c0e59e2 /android-app/app/src/test/kotlin/com
parent9f01ddfba17dda7fb386e83f007c671fec6d5b8e (diff)
refactor: unify core models and finish org.terst.nav migration
Diffstat (limited to 'android-app/app/src/test/kotlin/com')
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt91
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt180
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt33
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt317
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt178
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt105
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt169
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt32
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt135
9 files changed, 0 insertions, 1240 deletions
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt
deleted file mode 100644
index 535e46a..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt
+++ /dev/null
@@ -1,91 +0,0 @@
-package com.example.androidapp.data.weather
-
-import com.example.androidapp.data.model.GribFile
-import com.example.androidapp.data.model.GribRegion
-import com.example.androidapp.data.storage.InMemoryGribFileManager
-import org.junit.Assert.*
-import org.junit.Before
-import org.junit.Test
-import java.time.Instant
-
-class GribStalenessCheckerTest {
-
- private lateinit var manager: InMemoryGribFileManager
- private lateinit var checker: GribStalenessChecker
- private val region = GribRegion("test", 35.0, 40.0, -125.0, -120.0)
-
- @Before
- fun setUp() {
- manager = InMemoryGribFileManager()
- checker = GribStalenessChecker(manager)
- }
-
- private fun makeFile(
- modelRunTime: Instant,
- forecastHours: Int,
- downloadedAt: Instant = modelRunTime
- ) = GribFile(
- region = region,
- modelRunTime = modelRunTime,
- forecastHours = forecastHours,
- downloadedAt = downloadedAt,
- filePath = "/tmp/test.grib",
- sizeBytes = 1024L
- )
-
- @Test
- fun `check_returnsFresh_whenFileIsNotStale`() {
- val now = Instant.parse("2026-03-16T12:00:00Z")
- // model run at 06:00, 24h forecast → valid until 06:00 next day, well beyond now
- val file = makeFile(
- modelRunTime = Instant.parse("2026-03-16T06:00:00Z"),
- forecastHours = 24,
- downloadedAt = Instant.parse("2026-03-16T07:00:00Z")
- )
- manager.saveMetadata(file)
-
- val result = checker.check(region, now)
-
- assertTrue("Expected Fresh but got $result", result is FreshnessResult.Fresh)
- }
-
- @Test
- fun `check_returnsStale_whenFileIsExpired`() {
- val now = Instant.parse("2026-03-16T20:00:00Z")
- // model run at 06:00, 6h forecast → valid until 12:00; now is 8h after that
- val file = makeFile(
- modelRunTime = Instant.parse("2026-03-16T06:00:00Z"),
- forecastHours = 6,
- downloadedAt = Instant.parse("2026-03-16T07:00:00Z")
- )
- manager.saveMetadata(file)
-
- val result = checker.check(region, now)
-
- assertTrue("Expected Stale but got $result", result is FreshnessResult.Stale)
- val stale = result as FreshnessResult.Stale
- assertTrue("Message should contain hours outdated", stale.message.contains("8h"))
- assertEquals(file, stale.file)
- }
-
- @Test
- fun `check_returnsNoData_whenNoFilesForRegion`() {
- val otherRegion = GribRegion("other", 50.0, 55.0, -10.0, 0.0)
- val file = makeFile(
- modelRunTime = Instant.parse("2026-03-16T06:00:00Z"),
- forecastHours = 24
- )
- manager.saveMetadata(file)
-
- val result = checker.check(otherRegion, Instant.parse("2026-03-16T12:00:00Z"))
-
- assertEquals(FreshnessResult.NoData, result)
- }
-
- @Test
- fun `check_returnsNoData_whenManagerEmpty`() {
- val result = checker.check(region, Instant.now())
-
- assertEquals(FreshnessResult.NoData, result)
- }
-}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt
deleted file mode 100644
index 4bf7985..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt
+++ /dev/null
@@ -1,180 +0,0 @@
-package com.example.androidapp.data.weather
-
-import com.example.androidapp.data.model.GribParameter
-import com.example.androidapp.data.model.GribRegion
-import com.example.androidapp.data.model.SatelliteDownloadRequest
-import com.example.androidapp.data.storage.InMemoryGribFileManager
-import org.junit.Assert.*
-import org.junit.Before
-import org.junit.Test
-import java.time.Instant
-
-class SatelliteGribDownloaderTest {
-
- private lateinit var manager: InMemoryGribFileManager
- private lateinit var downloader: SatelliteGribDownloader
-
- // 10°×10° region at 1°: 11×11 = 121 grid points
- private val region10x10 = GribRegion("atlantic", 30.0, 40.0, -70.0, -60.0)
-
- @Before
- fun setUp() {
- manager = InMemoryGribFileManager()
- downloader = SatelliteGribDownloader(manager)
- }
-
- // ------------------------------------------------------------------ size estimation
-
- @Test
- fun `estimateSizeBytes_scalesWithRegionArea`() {
- // 10°×10° region: 11×11 = 121 grid points
- val req10 = SatelliteDownloadRequest(
- region = region10x10,
- parameters = GribParameter.SATELLITE_MINIMAL,
- forecastHours = 24
- )
- // 20°×20° region: 21×21 = 441 grid points — roughly 3.6× more grid points
- val region20x20 = GribRegion("bigger", 20.0, 40.0, -80.0, -60.0)
- val req20 = SatelliteDownloadRequest(
- region = region20x20,
- parameters = GribParameter.SATELLITE_MINIMAL,
- forecastHours = 24
- )
-
- val size10 = downloader.estimateSizeBytes(req10)
- val size20 = downloader.estimateSizeBytes(req20)
-
- assertTrue("Larger region must produce larger estimate", size20 > size10)
- }
-
- @Test
- fun `estimateSizeBytes_scalesWithParameterCount`() {
- val minimalReq = SatelliteDownloadRequest(
- region = region10x10,
- parameters = GribParameter.SATELLITE_MINIMAL, // 3 params
- forecastHours = 24
- )
- val fullReq = SatelliteDownloadRequest(
- region = region10x10,
- parameters = GribParameter.values().toSet(), // all 7 params
- forecastHours = 24
- )
-
- val sizeMinimal = downloader.estimateSizeBytes(minimalReq)
- val sizeFull = downloader.estimateSizeBytes(fullReq)
-
- assertTrue("More parameters must produce larger estimate", sizeFull > sizeMinimal)
- }
-
- @Test
- fun `estimateSizeBytes_coarserResolutionProducesSmallerFile`() {
- val finReq = SatelliteDownloadRequest(
- region = region10x10,
- parameters = GribParameter.SATELLITE_MINIMAL,
- forecastHours = 24,
- resolutionDeg = 1.0
- )
- val coarseReq = SatelliteDownloadRequest(
- region = region10x10,
- parameters = GribParameter.SATELLITE_MINIMAL,
- forecastHours = 24,
- resolutionDeg = 2.0
- )
-
- val sizeFine = downloader.estimateSizeBytes(finReq)
- val sizeCoarse = downloader.estimateSizeBytes(coarseReq)
-
- assertTrue("Coarser resolution must produce smaller estimate", sizeCoarse < sizeFine)
- }
-
- @Test
- fun `estimatedDownloadSeconds_atIridiumBandwidth`() {
- // 10°×10°, 3 params, 24h at 1° → known estimate
- val req = SatelliteDownloadRequest(
- region = region10x10,
- parameters = GribParameter.SATELLITE_MINIMAL,
- forecastHours = 24
- )
- val estBytes = downloader.estimateSizeBytes(req)
- val expectedSeconds = Math.ceil(estBytes * 8.0 / SatelliteGribDownloader.SATELLITE_BANDWIDTH_BPS).toLong()
-
- val actualSeconds = downloader.estimatedDownloadSeconds(req)
-
- assertEquals(expectedSeconds, actualSeconds)
- // Sanity: should be > 0 seconds and less than 10 minutes for a small region
- assertTrue("Download estimate must be positive", actualSeconds > 0)
- assertTrue("Small 10°×10° should complete in under 10 min at 2.4kbps", actualSeconds < 600)
- }
-
- // ------------------------------------------------------------------ buildMinimalRequest
-
- @Test
- fun `buildMinimalRequest_containsOnlyWindAndPressure`() {
- val req = downloader.buildMinimalRequest(region10x10, 48)
-
- assertEquals(GribParameter.SATELLITE_MINIMAL, req.parameters)
- assertTrue(req.parameters.contains(GribParameter.WIND_SPEED))
- assertTrue(req.parameters.contains(GribParameter.WIND_DIRECTION))
- assertTrue(req.parameters.contains(GribParameter.SURFACE_PRESSURE))
- assertFalse(req.parameters.contains(GribParameter.TEMPERATURE_2M))
- assertFalse(req.parameters.contains(GribParameter.PRECIPITATION))
- assertEquals(region10x10, req.region)
- assertEquals(48, req.forecastHours)
- }
-
- // ------------------------------------------------------------------ download()
-
- @Test
- fun `download_abortsWhenEstimatedSizeExceedsLimit`() {
- val req = downloader.buildMinimalRequest(region10x10, 24)
- var fetcherCalled = false
-
- val result = downloader.download(
- request = req,
- fetcher = { fetcherCalled = true; ByteArray(100) },
- outputPath = "/tmp/test.grib",
- sizeLimitBytes = 1L // ridiculously small limit
- )
-
- assertTrue("Should abort without calling fetcher", result is SatelliteGribDownloader.DownloadResult.Aborted)
- assertFalse("Fetcher must not be called when aborting", fetcherCalled)
- val aborted = result as SatelliteGribDownloader.DownloadResult.Aborted
- assertTrue("Should report estimated bytes", aborted.estimatedBytes > 0)
- }
-
- @Test
- fun `download_returnsFailedWhenFetcherReturnsNull`() {
- val req = downloader.buildMinimalRequest(region10x10, 24)
-
- val result = downloader.download(
- request = req,
- fetcher = { null },
- outputPath = "/tmp/test.grib"
- )
-
- assertTrue("Should fail when fetcher returns null", result is SatelliteGribDownloader.DownloadResult.Failed)
- }
-
- @Test
- fun `download_savesMetadataAndReturnsSuccessOnValidFetch`() {
- val req = downloader.buildMinimalRequest(region10x10, 24)
- val fakeBytes = ByteArray(8208) { 0x00 }
- val now = Instant.parse("2026-03-16T12:00:00Z")
-
- val result = downloader.download(
- request = req,
- fetcher = { fakeBytes },
- outputPath = "/tmp/atlantic.grib",
- now = now
- )
-
- assertTrue("Should succeed", result is SatelliteGribDownloader.DownloadResult.Success)
- val success = result as SatelliteGribDownloader.DownloadResult.Success
- assertEquals(region10x10, success.file.region)
- assertEquals(24, success.file.forecastHours)
- assertEquals(fakeBytes.size.toLong(), success.file.sizeBytes)
- assertEquals("/tmp/atlantic.grib", success.file.filePath)
- // Metadata must be persisted in the manager
- assertNotNull(manager.latestFile(region10x10))
- }
-}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt
deleted file mode 100644
index 8b2753c..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-package com.example.androidapp.gps
-
-import org.junit.Assert.*
-import org.junit.Test
-
-class GpsPositionTest {
-
- @Test
- fun `GpsPosition holds correct values`() {
- val pos = GpsPosition(
- latitude = 41.5,
- longitude = -71.0,
- sog = 5.2,
- cog = 180.0,
- timestampMs = 1_000L
- )
- assertEquals(41.5, pos.latitude, 0.0)
- assertEquals(-71.0, pos.longitude, 0.0)
- assertEquals(5.2, pos.sog, 0.0)
- assertEquals(180.0, pos.cog, 0.0)
- assertEquals(1_000L, pos.timestampMs)
- }
-
- @Test
- fun `GpsPosition equality works as expected for data class`() {
- val pos1 = GpsPosition(41.5, -71.0, 5.2, 180.0, 1_000L)
- val pos2 = GpsPosition(41.5, -71.0, 5.2, 180.0, 1_000L)
- val pos3 = GpsPosition(42.0, -70.0, 3.0, 90.0, 2_000L)
-
- assertEquals(pos1, pos2)
- assertNotEquals(pos1, pos3)
- }
-}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt
deleted file mode 100644
index 4eb9898..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt
+++ /dev/null
@@ -1,317 +0,0 @@
-package com.example.androidapp.gps
-
-import com.example.androidapp.data.model.SensorData
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.runBlocking
-import org.junit.Assert.*
-import org.junit.Test
-
-class LocationServiceTest {
-
- private fun service() = LocationService()
-
- // ── snapshot with no data ─────────────────────────────────────────────────
-
- @Test
- fun snapshot_noData_allFieldsNull() {
- val snap = service().snapshot()
- assertNull(snap.windSpeedKt)
- assertNull(snap.windDirectionDeg)
- assertNull(snap.currentSpeedKt)
- assertNull(snap.currentDirectionDeg)
- }
-
- // ── true-wind resolution ──────────────────────────────────────────────────
-
- @Test
- fun updateSensorData_withFullReading_resolvesTrueWind() = runBlocking {
- val svc = service()
- // Head north (hdg = 0°), AWS = 10 kt coming from ahead (AWA = 0°), BSP = 5 kt
- // → TW comes FROM ahead at 5 kt
- svc.updateSensorData(
- SensorData(
- headingTrueDeg = 0.0,
- apparentWindSpeedKt = 10.0,
- apparentWindAngleDeg = 0.0,
- speedOverGroundKt = 5.0
- )
- )
- val tw = svc.latestTrueWind.first()
- assertNotNull(tw)
- assertTrue("Expected TWS > 0", tw!!.speedKt > 0.0)
- }
-
- @Test
- fun updateSensorData_missingHeading_doesNotResolveTrueWind() = runBlocking {
- val svc = service()
- svc.updateSensorData(
- SensorData(
- apparentWindSpeedKt = 10.0,
- apparentWindAngleDeg = 45.0,
- speedOverGroundKt = 5.0
- // headingTrueDeg omitted
- )
- )
- assertNull(svc.latestTrueWind.first())
- }
-
- // ── current conditions ────────────────────────────────────────────────────
-
- @Test
- fun updateCurrentConditions_reflectedInSnapshot() {
- val svc = service()
- svc.updateCurrentConditions(speedKt = 1.5, directionDeg = 135.0)
-
- val snap = svc.snapshot()
- assertEquals(1.5, snap.currentSpeedKt!!, 0.001)
- assertEquals(135.0, snap.currentDirectionDeg!!, 0.001)
- }
-
- @Test
- fun updateCurrentConditions_nullClears() {
- val svc = service()
- svc.updateCurrentConditions(speedKt = 2.0, directionDeg = 90.0)
- svc.updateCurrentConditions(speedKt = null, directionDeg = null)
-
- val snap = svc.snapshot()
- assertNull(snap.currentSpeedKt)
- assertNull(snap.currentDirectionDeg)
- }
-
- // ── combined snapshot ─────────────────────────────────────────────────────
-
- @Test
- fun snapshot_afterFullUpdate_populatesAllFields() = runBlocking {
- val svc = service()
-
- // Head east (hdg = 90°), wind from starboard bow, BSP proxy = 6 kt
- svc.updateSensorData(
- SensorData(
- headingTrueDeg = 90.0,
- apparentWindSpeedKt = 12.0,
- apparentWindAngleDeg = 45.0,
- speedOverGroundKt = 6.0
- )
- )
- svc.updateCurrentConditions(speedKt = 0.8, directionDeg = 270.0)
-
- val snap = svc.snapshot()
- assertNotNull(snap.windSpeedKt)
- assertNotNull(snap.windDirectionDeg)
- assertEquals(0.8, snap.currentSpeedKt!!, 0.001)
- assertEquals(270.0, snap.currentDirectionDeg!!, 0.001)
- }
-
- // ── latestSensor flow ─────────────────────────────────────────────────────
-
- @Test
- fun updateSensorData_updatesLatestSensorFlow() = runBlocking {
- val svc = service()
- assertNull(svc.latestSensor.first())
-
- val data = SensorData(latitude = 41.5, longitude = -71.3)
- svc.updateSensorData(data)
-
- assertEquals(data, svc.latestSensor.first())
- }
-
- // ── GPS sensor fusion ─────────────────────────────────────────────────────
-
- private fun fusionService(
- nmeaStalenessThresholdMs: Long = 5_000L,
- nmeaExtendedThresholdMs: Long = 10_000L,
- clockMs: () -> Long = System::currentTimeMillis
- ) = LocationService(
- nmeaStalenessThresholdMs = nmeaStalenessThresholdMs,
- nmeaExtendedThresholdMs = nmeaExtendedThresholdMs,
- clockMs = clockMs
- )
-
- private fun pos(lat: Double, lon: Double, timestampMs: Long) =
- GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs)
-
- private fun posWithAccuracy(lat: Double, lon: Double, timestampMs: Long, accuracyMeters: Double) =
- GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs, accuracyMeters = accuracyMeters)
-
- @Test
- fun noGpsData_bestPositionNullAndSourceNone() = runBlocking {
- val svc = fusionService()
- assertNull(svc.bestPosition.first())
- assertEquals(GpsSource.NONE, svc.activeGpsSource.first())
- }
-
- @Test
- fun freshNmea_preferredOverAndroid() = runBlocking {
- val now = 10_000L
- val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
-
- val nmeaFix = pos(41.0, -71.0, now)
- val androidFix = pos(42.0, -72.0, now - 1_000L)
-
- svc.updateAndroidGps(androidFix)
- svc.updateNmeaGps(nmeaFix)
-
- assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
- assertEquals(nmeaFix, svc.bestPosition.first())
- }
-
- @Test
- fun staleNmea_androidFallback() = runBlocking {
- val nmeaTime = 0L
- val now = 10_000L // 10 s later — NMEA is stale (threshold 5 s)
- val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
-
- val nmeaFix = pos(41.0, -71.0, nmeaTime)
- val androidFix = pos(42.0, -72.0, now)
-
- svc.updateNmeaGps(nmeaFix)
- svc.updateAndroidGps(androidFix)
-
- assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
- assertEquals(androidFix, svc.bestPosition.first())
- }
-
- @Test
- fun onlyNmeaAvailable_usedEvenWhenStale() = runBlocking {
- val now = 60_000L // 60 s after fix — very stale
- val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
-
- val nmeaFix = pos(41.0, -71.0, 0L)
- svc.updateNmeaGps(nmeaFix)
-
- assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
- assertEquals(nmeaFix, svc.bestPosition.first())
- }
-
- @Test
- fun onlyAndroidAvailable_isUsed() = runBlocking {
- val svc = fusionService()
- val androidFix = pos(42.0, -72.0, System.currentTimeMillis())
- svc.updateAndroidGps(androidFix)
-
- assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
- assertEquals(androidFix, svc.bestPosition.first())
- }
-
- @Test
- fun nmeaAtExactThreshold_isConsideredFresh() = runBlocking {
- val fixTime = 0L
- val now = 5_000L // exactly at threshold
- val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
-
- val nmeaFix = pos(41.0, -71.0, fixTime)
- val androidFix = pos(42.0, -72.0, now)
-
- svc.updateNmeaGps(nmeaFix)
- svc.updateAndroidGps(androidFix)
-
- assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
- }
-
- // ── fix-quality (accuracy) tie-breaking ──────────────────────────────────
-
- @Test
- fun marginallyStaleNmea_betterAccuracy_preferredOverAndroid() = runBlocking {
- // NMEA is 7 s old (> primary 5 s, ≤ extended 10 s) but has accuracy 3 m vs Android 15 m.
- val nmeaTime = 0L
- val now = 7_000L
- val svc = fusionService(
- nmeaStalenessThresholdMs = 5_000L,
- nmeaExtendedThresholdMs = 10_000L,
- clockMs = { now }
- )
-
- val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 3.0)
- val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 15.0)
-
- svc.updateNmeaGps(nmeaFix)
- svc.updateAndroidGps(androidFix)
-
- assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
- assertEquals(nmeaFix, svc.bestPosition.first())
- }
-
- @Test
- fun marginallyStaleNmea_worseAccuracy_fallsBackToAndroid() = runBlocking {
- // NMEA is 7 s old with accuracy 15 m; Android has accuracy 3 m → Android wins.
- val nmeaTime = 0L
- val now = 7_000L
- val svc = fusionService(
- nmeaStalenessThresholdMs = 5_000L,
- nmeaExtendedThresholdMs = 10_000L,
- clockMs = { now }
- )
-
- val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 15.0)
- val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 3.0)
-
- svc.updateNmeaGps(nmeaFix)
- svc.updateAndroidGps(androidFix)
-
- assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
- assertEquals(androidFix, svc.bestPosition.first())
- }
-
- @Test
- fun marginallyStaleNmea_noAccuracyData_fallsBackToAndroid() = runBlocking {
- // Neither source has accuracy metadata — conservative: prefer Android.
- val nmeaTime = 0L
- val now = 7_000L
- val svc = fusionService(
- nmeaStalenessThresholdMs = 5_000L,
- nmeaExtendedThresholdMs = 10_000L,
- clockMs = { now }
- )
-
- val nmeaFix = pos(41.0, -71.0, nmeaTime)
- val androidFix = pos(42.0, -72.0, now)
-
- svc.updateNmeaGps(nmeaFix)
- svc.updateAndroidGps(androidFix)
-
- assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
- }
-
- @Test
- fun veryStaleNmea_beyondExtendedThreshold_androidPreferred() = runBlocking {
- // NMEA is 15 s old (beyond extended 10 s); Android wins even if NMEA has better accuracy.
- val nmeaTime = 0L
- val now = 15_000L
- val svc = fusionService(
- nmeaStalenessThresholdMs = 5_000L,
- nmeaExtendedThresholdMs = 10_000L,
- clockMs = { now }
- )
-
- val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 2.0)
- val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 20.0)
-
- svc.updateNmeaGps(nmeaFix)
- svc.updateAndroidGps(androidFix)
-
- assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
- assertEquals(androidFix, svc.bestPosition.first())
- }
-
- @Test
- fun nmeaRecovery_switchesBackFromAndroid() = runBlocking {
- var now = 0L
- val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
-
- // Fresh NMEA
- svc.updateNmeaGps(pos(41.0, -71.0, 0L))
- assertEquals(GpsSource.NMEA, svc.activeGpsSource.value)
-
- // NMEA goes stale; Android takes over
- now = 10_000L
- val androidFix = pos(42.0, -72.0, 10_000L)
- svc.updateAndroidGps(androidFix)
- assertEquals(GpsSource.ANDROID, svc.activeGpsSource.value)
-
- // NMEA recovers with a fresh fix
- val freshNmea = pos(41.1, -71.1, 10_000L)
- svc.updateNmeaGps(freshNmea)
- assertEquals(GpsSource.NMEA, svc.activeGpsSource.value)
- assertEquals(freshNmea, svc.bestPosition.value)
- }
-}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt
deleted file mode 100644
index 30b421f..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt
+++ /dev/null
@@ -1,178 +0,0 @@
-package com.example.androidapp.logbook
-
-import com.example.androidapp.data.model.LogbookEntry
-import org.junit.Assert.*
-import org.junit.Test
-
-class LogbookFormatterTest {
-
- // 2021-06-15 08:00:00 UTC = 1623744000000 ms
- private val t0 = 1_623_744_000_000L
-
- private fun entry(
- ts: Long = t0,
- lat: Double = 41.39,
- lon: Double = -71.202,
- sog: Double = 6.2,
- cog: Double = 225.0,
- windKt: Double? = 15.0,
- windDir: Double? = 225.0,
- baro: Double? = 1018.0,
- depth: Double? = 14.0,
- event: String? = "Departed slip",
- notes: String? = null
- ) = LogbookEntry(ts, lat, lon, sog, cog, windKt, windDir, baro, depth, event, notes)
-
- // --- formatTime ---
-
- @Test
- fun `formatTime returns HH_MM for UTC midnight`() {
- // 2021-06-15 00:00:00 UTC
- val ts = 1_623_715_200_000L
- assertEquals("00:00", LogbookFormatter.formatTime(ts))
- }
-
- @Test
- fun `formatTime returns correct UTC hour for known timestamp`() {
- // t0 = 2021-06-15 08:00:00 UTC
- assertEquals("08:00", LogbookFormatter.formatTime(t0))
- }
-
- @Test
- fun `formatTime pads single-digit hour and minute`() {
- // 2021-06-15 01:05:00 UTC = 1623715200000 + 65*60*1000 = 1623715200000 + 3900000
- val ts = 1_623_715_200_000L + 65 * 60_000L
- assertEquals("01:05", LogbookFormatter.formatTime(ts))
- }
-
- // --- formatPosition ---
-
- @Test
- fun `formatPosition north east`() {
- // 41.39°N → 41°23.4N, 71.202°E → 71°12.1E
- val result = LogbookFormatter.formatPosition(41.39, 71.202)
- assertEquals("41°23.4N 71°12.1E", result)
- }
-
- @Test
- fun `formatPosition south west`() {
- // -41.39°S → 41°23.4S, -71.202°W → 71°12.1W
- val result = LogbookFormatter.formatPosition(-41.39, -71.202)
- assertEquals("41°23.4S 71°12.1W", result)
- }
-
- @Test
- fun `formatPosition zero zero`() {
- val result = LogbookFormatter.formatPosition(0.0, 0.0)
- assertEquals("0°0.0N 0°0.0E", result)
- }
-
- // --- formatWind ---
-
- @Test
- fun `formatWind null knots returns empty string`() {
- assertEquals("", LogbookFormatter.formatWind(null, null))
- }
-
- @Test
- fun `formatWind with knots and null direction returns knots only`() {
- assertEquals("15kt", LogbookFormatter.formatWind(15.0, null))
- }
-
- @Test
- fun `formatWind 225 degrees is SW`() {
- assertEquals("15kt SW", LogbookFormatter.formatWind(15.0, 225.0))
- }
-
- @Test
- fun `formatWind 0 degrees is N`() {
- assertEquals("10kt N", LogbookFormatter.formatWind(10.0, 0.0))
- }
-
- @Test
- fun `formatWind 360 degrees is N`() {
- assertEquals("10kt N", LogbookFormatter.formatWind(10.0, 360.0))
- }
-
- @Test
- fun `formatWind 90 degrees is E`() {
- assertEquals("8kt E", LogbookFormatter.formatWind(8.0, 90.0))
- }
-
- // --- toCompassPoint ---
-
- @Test
- fun `toCompassPoint covers all 16 cardinal and intercardinal points`() {
- val expected = listOf("N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
- "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW")
- expected.forEachIndexed { i, dir ->
- val degrees = i * 22.5
- assertEquals("degrees=$degrees", dir, LogbookFormatter.toCompassPoint(degrees))
- }
- }
-
- // --- toRow ---
-
- @Test
- fun `toRow formats all fields correctly`() {
- val row = LogbookFormatter.toRow(entry())
- assertEquals("08:00", row.time)
- assertEquals("41°23.4N 71°12.1W", row.position)
- assertEquals("6.2", row.sog)
- assertEquals("225", row.cog)
- assertEquals("15kt SW", row.wind)
- assertEquals("1018", row.baro)
- assertEquals("14m", row.depth)
- assertEquals("Departed slip", row.eventNotes)
- }
-
- @Test
- fun `toRow combines event and notes with colon`() {
- val row = LogbookFormatter.toRow(entry(event = "Reef #1", notes = "Strong gusts"))
- assertEquals("Reef #1: Strong gusts", row.eventNotes)
- }
-
- @Test
- fun `toRow with only notes has no colon prefix`() {
- val row = LogbookFormatter.toRow(entry(event = null, notes = "Calm seas"))
- assertEquals("Calm seas", row.eventNotes)
- }
-
- @Test
- fun `toRow with null optional fields uses empty strings`() {
- val e = LogbookEntry(t0, 0.0, 0.0, 0.0, 0.0)
- val row = LogbookFormatter.toRow(e)
- assertEquals("", row.wind)
- assertEquals("", row.baro)
- assertEquals("", row.depth)
- assertEquals("", row.eventNotes)
- }
-
- // --- toPage ---
-
- @Test
- fun `toPage returns page with default title and correct column count`() {
- val page = LogbookFormatter.toPage(emptyList())
- assertEquals("Trip Logbook", page.title)
- assertEquals(8, page.columns.size)
- }
-
- @Test
- fun `toPage maps entries to rows in order`() {
- val entries = listOf(
- entry(ts = t0, event = "First"),
- entry(ts = t0 + 3_600_000L, event = "Second")
- )
- val page = LogbookFormatter.toPage(entries, "Voyage Log")
- assertEquals("Voyage Log", page.title)
- assertEquals(2, page.rows.size)
- assertEquals("First", page.rows[0].eventNotes)
- assertEquals("Second", page.rows[1].eventNotes)
- }
-
- @Test
- fun `toPage empty entries produces empty rows`() {
- val page = LogbookFormatter.toPage(emptyList())
- assertTrue(page.rows.isEmpty())
- }
-}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt
deleted file mode 100644
index b8a878a..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-package com.example.androidapp.nmea
-
-import org.junit.Assert.*
-import org.junit.Before
-import org.junit.Test
-
-class NmeaParserTest {
-
- private lateinit var parser: NmeaParser
-
- @Before
- fun setUp() {
- parser = NmeaParser()
- }
-
- // $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
- // lat: 48 + 7.038/60 = 48.1173°N, lon: 11 + 31.000/60 = 11.51667°E
- // SOG 22.4 kn, COG 84.4°
-
- @Test
- fun `valid RMC sentence parses latitude and longitude`() {
- val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A"
- val pos = parser.parseRmc(sentence)
- assertNotNull(pos)
- assertEquals(48.1173, pos!!.latitude, 0.0001)
- assertEquals(11.51667, pos.longitude, 0.0001)
- }
-
- @Test
- fun `valid RMC sentence parses SOG and COG`() {
- val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A"
- val pos = parser.parseRmc(sentence)
- assertNotNull(pos)
- assertEquals(22.4, pos!!.sog, 0.001)
- assertEquals(84.4, pos.cog, 0.001)
- }
-
- @Test
- fun `void status V returns null`() {
- val sentence = "\$GPRMC,123519,V,4807.038,N,01131.000,E,,,230394,003.1,W"
- assertNull(parser.parseRmc(sentence))
- }
-
- @Test
- fun `malformed sentence with too few fields returns null`() {
- assertNull(parser.parseRmc("\$GPRMC,123519,A"))
- }
-
- @Test
- fun `empty string returns null`() {
- assertNull(parser.parseRmc(""))
- }
-
- @Test
- fun `non-RMC sentence returns null`() {
- val sentence = "\$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,"
- assertNull(parser.parseRmc(sentence))
- }
-
- @Test
- fun `south latitude is negative`() {
- // lat: -(42 + 50.5589/60) = -42.84265
- val sentence = "\$GPRMC,092204.999,A,4250.5589,S,14718.5084,E,0.00,89.68,211200,,"
- val pos = parser.parseRmc(sentence)
- assertNotNull(pos)
- assertTrue("South latitude must be negative", pos!!.latitude < 0)
- assertEquals(-42.84265, pos.latitude, 0.0001)
- }
-
- @Test
- fun `west longitude is negative`() {
- // lon: -(11 + 31.000/60) = -11.51667
- val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,W,022.4,084.4,230394,003.1,E"
- val pos = parser.parseRmc(sentence)
- assertNotNull(pos)
- assertTrue("West longitude must be negative", pos!!.longitude < 0)
- assertEquals(-11.51667, pos.longitude, 0.0001)
- }
-
- @Test
- fun `SOG and COG parse with decimal precision`() {
- val sentence = "\$GPRMC,093456,A,3352.1234,N,11801.5678,W,12.345,270.5,140326,,"
- val pos = parser.parseRmc(sentence)
- assertNotNull(pos)
- assertEquals(12.345, pos!!.sog, 0.0001)
- assertEquals(270.5, pos.cog, 0.0001)
- }
-
- @Test
- fun `empty SOG and COG fields default to zero`() {
- val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,,,230394,003.1,W"
- val pos = parser.parseRmc(sentence)
- assertNotNull(pos)
- assertEquals(0.0, pos!!.sog, 0.001)
- assertEquals(0.0, pos.cog, 0.001)
- }
-
- @Test
- fun `GNRMC talker ID is also accepted`() {
- val sentence = "\$GNRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W"
- val pos = parser.parseRmc(sentence)
- assertNotNull(pos)
- assertEquals(48.1173, pos!!.latitude, 0.0001)
- }
-}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt
deleted file mode 100644
index e5615e9..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt
+++ /dev/null
@@ -1,169 +0,0 @@
-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])
- }
-}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt
deleted file mode 100644
index 40f7df0..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.example.androidapp.safety
-
-import org.junit.Assert.*
-import org.junit.Test
-import kotlin.math.sqrt
-
-class AnchorWatchStateTest {
-
- private val state = AnchorWatchState()
-
- @Test
- fun calculateRecommendedWatchCircleRadius_validGeometry() {
- // depth=6m, rode=50m → vertical=8m, radius=sqrt(50²-8²)=sqrt(2436)
- val expected = sqrt(2436.0)
- val actual = state.calculateRecommendedWatchCircleRadius(depthM = 6.0, rodeOutM = 50.0)
- assertEquals(expected, actual, 0.001)
- }
-
- @Test
- fun calculateRecommendedWatchCircleRadius_rodeShorterThanVertical_fallsBackToRode() {
- // depth=10m, rode=5m → vertical=12m > rode, fallback returns rode
- val actual = state.calculateRecommendedWatchCircleRadius(depthM = 10.0, rodeOutM = 5.0)
- assertEquals(5.0, actual, 0.001)
- }
-
- @Test
- fun calculateRecommendedWatchCircleRadius_rodeEqualsVertical_fallsBackToRode() {
- // depth=8m, rode=10m → vertical=10m == rode, fallback returns rode
- val actual = state.calculateRecommendedWatchCircleRadius(depthM = 8.0, rodeOutM = 10.0)
- assertEquals(10.0, actual, 0.001)
- }
-}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt
deleted file mode 100644
index 612ae34..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt
+++ /dev/null
@@ -1,135 +0,0 @@
-package com.example.androidapp.tide
-
-import com.example.androidapp.data.model.TideConstituent
-import com.example.androidapp.data.model.TideStation
-import org.junit.Assert.*
-import org.junit.Test
-
-class HarmonicTideCalculatorTest {
-
- // Reference epoch: 2000-01-01 00:00:00 UTC = 946_684_800_000 ms
- private val epochMs = HarmonicTideCalculator.EPOCH_MS
- private val oneHourMs = 3_600_000L
-
- private fun stationWith(
- speed: Double = 30.0,
- amplitude: Double = 1.0,
- phase: Double = 0.0,
- datum: Double = 0.0
- ) = TideStation(
- id = "test", name = "Test", lat = 0.0, lon = 0.0,
- datumOffsetMeters = datum,
- constituents = listOf(TideConstituent("S2", speed, amplitude, phase))
- )
-
- @Test
- fun `predictHeight at epoch gives datum plus amplitude for zero-phase constituent`() {
- val station = stationWith(speed = 30.0, amplitude = 1.5, phase = 0.0, datum = 0.5)
- val height = HarmonicTideCalculator.predictHeight(station, epochMs)
- assertEquals(0.5 + 1.5, height, 1e-9) // cos(0°) = 1.0
- }
-
- @Test
- fun `predictHeight at half period gives datum minus amplitude`() {
- // speed = 30 deg/hr → half period = 6 hours → cos(180°) = -1.0
- val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 0.0, datum = 0.0)
- val height = HarmonicTideCalculator.predictHeight(station, epochMs + 6 * oneHourMs)
- assertEquals(-1.0, height, 1e-9)
- }
-
- @Test
- fun `predictHeight at quarter period is near zero`() {
- // speed = 30 deg/hr → quarter period = 3 hours → cos(90°) ≈ 0.0
- val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 0.0, datum = 0.0)
- val height = HarmonicTideCalculator.predictHeight(station, epochMs + 3 * oneHourMs)
- assertEquals(0.0, height, 1e-9)
- }
-
- @Test
- fun `predictHeight applies phase offset correctly`() {
- // phase = 90 → cos(0 - 90°) = cos(-90°) ≈ 0.0 at epoch
- val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 90.0, datum = 0.0)
- val height = HarmonicTideCalculator.predictHeight(station, epochMs)
- assertEquals(0.0, height, 1e-9)
- }
-
- @Test
- fun `predictHeight sums multiple constituents at epoch`() {
- val station = TideStation(
- id = "test", name = "Test", lat = 0.0, lon = 0.0,
- datumOffsetMeters = 2.0,
- constituents = listOf(
- TideConstituent("S2", 30.0, 1.0, 0.0), // +1.0 at epoch
- TideConstituent("K1", 30.0, 0.5, 0.0) // +0.5 at epoch
- )
- )
- val height = HarmonicTideCalculator.predictHeight(station, epochMs)
- assertEquals(3.5, height, 1e-9) // 2.0 + 1.0 + 0.5
- }
-
- @Test
- fun `predictHeight with empty constituents returns datum offset only`() {
- val station = TideStation("t", "T", 0.0, 0.0, 3.14, emptyList())
- assertEquals(3.14, HarmonicTideCalculator.predictHeight(station, epochMs), 1e-9)
- }
-
- @Test
- fun `predictRange returns correct number of predictions`() {
- val station = stationWith()
- val predictions = HarmonicTideCalculator.predictRange(
- station, epochMs, epochMs + 3 * oneHourMs, oneHourMs
- )
- assertEquals(4, predictions.size) // t=0h, 1h, 2h, 3h
- }
-
- @Test
- fun `predictRange timestamps are evenly spaced`() {
- val station = stationWith()
- val predictions = HarmonicTideCalculator.predictRange(
- station, epochMs, epochMs + 2 * oneHourMs, oneHourMs
- )
- assertEquals(epochMs, predictions[0].timestampMs)
- assertEquals(epochMs + oneHourMs, predictions[1].timestampMs)
- assertEquals(epochMs + 2 * oneHourMs, predictions[2].timestampMs)
- }
-
- @Test
- fun `predictRange with equal from and to returns single prediction`() {
- val station = stationWith()
- val predictions = HarmonicTideCalculator.predictRange(station, epochMs, epochMs, oneHourMs)
- assertEquals(1, predictions.size)
- assertEquals(epochMs, predictions[0].timestampMs)
- }
-
- @Test
- fun `findHighLow returns empty list for fewer than 3 predictions`() {
- val station = stationWith()
- val predictions = HarmonicTideCalculator.predictRange(
- station, epochMs, epochMs + oneHourMs, oneHourMs
- )
- assertEquals(2, predictions.size)
- assertTrue(HarmonicTideCalculator.findHighLow(predictions).isEmpty())
- }
-
- @Test
- fun `findHighLow detects high and low water events`() {
- // speed = 30 deg/hr, 3-hour samples over 24 hours
- // Heights: 1.0, 0.0, -1.0, 0.0, 1.0, 0.0, -1.0, 0.0, 1.0
- // Turning points at t=6h(low), t=12h(high), t=18h(low)
- val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 0.0, datum = 0.0)
- val predictions = HarmonicTideCalculator.predictRange(
- station,
- epochMs,
- epochMs + 24 * oneHourMs,
- 3 * oneHourMs
- )
- val highLow = HarmonicTideCalculator.findHighLow(predictions)
- assertEquals(3, highLow.size)
- assertEquals(epochMs + 6 * oneHourMs, highLow[0].timestampMs)
- assertEquals(-1.0, highLow[0].heightMeters, 1e-9)
- assertEquals(epochMs + 12 * oneHourMs, highLow[1].timestampMs)
- assertEquals(1.0, highLow[1].heightMeters, 1e-9)
- assertEquals(epochMs + 18 * oneHourMs, highLow[2].timestampMs)
- assertEquals(-1.0, highLow[2].heightMeters, 1e-9)
- }
-}