summaryrefslogtreecommitdiff
path: root/android-app/app/src/test/kotlin
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-04-02 11:28:00 -1000
committerGitHub <noreply@github.com>2026-04-02 11:28:00 -1000
commit5416c8ccc9220bc36e7af3febcbdd9d86e88cf30 (patch)
tree46e990ae9dc43f4f7b6ea75cb4e5467d6fdb6359 /android-app/app/src/test/kotlin
parenta9d87b600848178b03b85a06ccdfd53b11e38c38 (diff)
fix(ui): make record track FAB visible (#1)
* Add GpsPosition data class and NMEA RMC parser with tests - GpsPosition: latitude, longitude, sog (knots), cog (degrees true), timestampMs - NmeaParser.parseRmc: handles GP/GN talker IDs, void status, malformed input - SOG/COG default to 0.0 when fields absent; S/W coords are negative - 13 unit tests: GpsPositionTest (2), NmeaParserTest (11) — all GREEN Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add harmonic tide height predictions (Section 3.2 / 4.2) Implement offline harmonic tide prediction as specified in COMPONENT_DESIGN.md: - TideConstituent: name, speedDegPerHour, amplitudeMeters, phaseDeg - TidePrediction: timestampMs, heightMeters - TideStation: id, name, lat, lon, datumOffsetMeters, constituents - HarmonicTideCalculator: predictHeight(), predictRange(), findHighLow() Formula: h(t) = Z0 + Σ [ Hi × cos( ωi × (t − t0) − φi ) ] - 15 unit tests covering all calculation paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: implement isochrone-based weather routing (Section 3.4) * feat: implement PDF logbook export (Section 4.8) - LogbookEntry data class: timestampMs, lat/lon, SOG, COG, wind, baro, depth, event/notes - LogbookFormatter: UTC time, position (deg/dec-min), 16-pt compass, row/page builders - LogbookPdfExporter: landscape A4 PDF via android.graphics.pdf.PdfDocument with column headers, alternating row shading, and table border - 20 unit tests covering all formatting helpers and data model behaviour Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: offline GRIB staleness checker, ViewModel integration, and UI badge - Add GribRegion, GribFile data models and GribFileManager interface - Add InMemoryGribFileManager for testing and default use - Add GribStalenessChecker with FreshnessResult sealed class (Fresh/Stale/NoData) - Integrate weatherStaleness StateFlow into MainViewModel (checked after loadWeather) - Add yellow staleness banner TextView to fragment_map.xml - Wire staleness banner in MapFragment (shown on Stale, hidden on Fresh/NoData) - Add GribStalenessCheckerTest (4 TDD tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: satellite GRIB download with bandwidth optimisation (§9.1) Implements weather data download over Iridium satellite links: - GribParameter enum with SATELLITE_MINIMAL set (wind + pressure only) - SatelliteDownloadRequest data class (region, params, forecast hours, resolution) - SatelliteGribDownloader: size/time estimation, abort-on-oversized, pluggable fetcher - 8 unit tests covering estimation scaling, minimal param set, and download outcomes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add AnchorWatchHandler UI with Depth/Rode Out inputs and suggested radius - Add AnchorWatchState with calculateRecommendedWatchCircleRadius, which uses ScopeCalculator.watchCircleRadius (Pythagorean scope formula) and falls back to rode length when geometry is invalid - Add AnchorWatchHandler Fragment with EditText inputs for Depth (m) and Rode Out (m); updates suggested watch circle radius live via TextWatcher - Add fragment_anchor_watch.xml layout - Wire AnchorWatchHandler into bottom navigation (MainActivity + menu) - Add AnchorWatchStateTest covering valid geometry, short-rode fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(safety): log wind and current conditions at MOB activation (Section 4.6) Per COMPONENT_DESIGN.md Section 4.6, the MOB navigation view must display wind and current conditions at the time of the event. - MobEvent: add nullable windSpeedKt, windDirectionDeg, currentSpeedKt, currentDirectionDeg fields captured at the exact moment of activation - MobAlarmManager.activate(): accept optional wind/current params and forward them into MobEvent (defaults to null for backward compatibility) - LocationService (new): aggregates live SensorData (resolves true wind via TrueWindCalculator) and marine-forecast current conditions; snapshot() provides a point-in-time EnvironmentalSnapshot for safety-critical logging - MobAlarmManagerTest: add tests for wind/current storage and null defaults - LocationServiceTest (new): covers snapshot, true-wind resolution, current-condition updates, and the latestSensor flow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(gps): implement NMEA/Android GPS sensor fusion in LocationService Adds priority-based selection between NMEA GPS (dedicated marine GPS, higher priority) and Android system GPS (fallback) within LocationService. Selection policy: 1. Prefer NMEA when its most recent fix is fresh (≤ nmeaStalenessThresholdMs, default 5 s) 2. Fall back to Android GPS when NMEA is stale 3. Use stale NMEA only when Android has never reported a fix 4. bestPosition is null until at least one source reports New public API: - GpsSource enum (NONE, NMEA, ANDROID) - LocationService.updateNmeaGps(GpsPosition) - LocationService.updateAndroidGps(GpsPosition) - LocationService.bestPosition: StateFlow<GpsPosition?> - LocationService.activeGpsSource: StateFlow<GpsSource> - Injectable clockMs parameter for deterministic unit tests Adds 7 unit tests covering: no-data state, fresh NMEA priority, stale NMEA fallback, only-NMEA/only-Android scenarios, exact-threshold edge case, and NMEA recovery after Android takeover. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(gps): add fix-quality (accuracy) tier to GPS sensor fusion Extend LocationService's source-selection policy with a quality-aware "marginal staleness" zone between the primary and a new extended staleness threshold (default 10 s): 1. Fresh NMEA (≤ primary threshold, 5 s) → always prefer NMEA 2. Marginally stale NMEA (5–10 s) → prefer NMEA only when GpsPosition.accuracyMeters is strictly better than Android's; fall back to Android conservatively when accuracy data is absent 3. Very stale NMEA (> 10 s) → always prefer Android 4. Only one source available → use it regardless of age Changes: - GpsPosition: add nullable accuracyMeters field (default null, no breaking change to existing callers) - LocationService: add nmeaExtendedThresholdMs constructor parameter; recomputeBestPosition() now implements three-tier logic; extract GpsPosition.hasStrictlyBetterAccuracyThan() helper - LocationServiceTest: expose nmeaExtendedThresholdMs in fusionService helper; add posWithAccuracy helper; add 4 new test cases covering accuracy-based NMEA preference, worse-accuracy fallback, no-accuracy conservative fallback, and very-stale unconditional fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add .gitignore, pull-crash-logs script, updated agent permissions - .gitignore: exclude agent artifacts (.claudomator-env, .agent-home/, crash-logs/) - scripts/pull-crash-logs: download Crashlytics crash reports via Firebase CLI - .claude/settings.local.json: add Android SDK/emulator/adb permission rules Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update CI workflow to target main branch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve all Kotlin compilation errors blocking CI build - Migrate kapt → KSP (Kotlin 2.0 + kapt is broken; KSP is the supported path) - Fix duplicate onResume() override in MainActivity - Fix wrong package imports: com.example.androidapp.data.model → org.terst.nav.data.model across GribFileManager, GribStalenessChecker, SatelliteGribDownloader, LogbookFormatter, LogbookPdfExporter, IsochroneRouter, AnchorWatchHandler - Create missing SensorData, ApparentWind, TrueWindData, TrueWindCalculator classes - Inline missing ScopeCalculator formula (Pythagorean) in AnchorWatchState Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(track): implement GPS track recording with map overlay - TrackRepository + TrackPoint wired into MainViewModel: isRecording/trackPoints StateFlows, startTrack/stopTrack/addGpsPoint - MapHandler.updateTrackLayer(): lazily initialises a red LineLayer and updates GeoJSON polyline from List<TrackPoint> - fab_record_track FAB in activity_main.xml (top|end of bottom nav); icon toggles between ic_track_record and ic_close while recording - MainActivity feeds every GPS fix into viewModel.addGpsPoint() and observes trackPoints to redraw the polyline in real time - ic_track_record.xml vector drawable (red record dot) - 8 TrackRepositoryTest tests all GREEN Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(ci): share APKs between jobs and expand smoke tests CI — build job now uploads both APKs as the 'test-apks' artifact. smoke-test job downloads them and passes -x assembleDebug -x assembleDebugAndroidTest to skip recompilation (~4 min saved). Test results uploaded as 'smoke-test-results' artifact on every run. Smoke tests expanded from 1 → 11 tests covering: - MainActivity launches without crash - All 4 bottom-nav tabs are displayed - Safety tab: Safety Dashboard, ACTIVATE MOB, ANCHOR WATCH visible - Log tab: voice-log mic FAB visible - Instruments tab: bottom sheet displayed - Map tab: returns from overlay, mapView visible - MOB FAB: always visible, visible on Safety tab - Record Track FAB: displayed, toggles to Stop Recording, toggles back MainActivity: moved isRecording observer to initializeUI() so the FAB content description updates without requiring GPS permission (needed for emulator tests that run without location permission). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: address simplify review findings TrackRepository.addPoint() now returns Boolean (true if point was added). MainViewModel.addGpsPoint() only updates _trackPoints StateFlow when a point is actually appended — eliminates ~3,600 no-op flow emissions per hour when not recording. MainActivity: loadedStyle promoted from nullable field to MutableStateFlow<Style?>; trackPoints observer uses filterNotNull + combine so no track points are silently dropped if the style loads after the first GPS fix. Smoke tests: replaced 11× ActivityScenario.launch().use{} with a single @get:Rule ActivityScenarioRule — same isolation, less boilerplate. CI: removed redundant app-debug artifact upload (app-debug.apk is already included inside the test-apks artifact). Removed stale/placeholder comments from MainActivity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ui): anchor record track FAB to instrument sheet to prevent it being hidden The FAB was anchored to bottom_navigation, placing it in the peek zone of the instrument bottom sheet (120dp) and making it invisible to users. Re-anchoring to instrument_bottom_sheet keeps the button visible and makes it track naturally with sheet drag gestures. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ui): anchor MOB FAB to instrument sheet to match record-track FAB The fab_mob was still anchored to bottom_navigation using the same top|start pattern that caused fab_record_track to be hidden behind the instrument sheet's 16dp elevation. Apply the same fix so the safety-critical MOB button is not occluded. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claudomator Agent <agent@claudomator> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Agent <agent@example.com> Co-authored-by: Claude Agent <agent@claude.ai>
Diffstat (limited to 'android-app/app/src/test/kotlin')
-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
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt67
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt56
-rw-r--r--android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt0
12 files changed, 1363 insertions, 0 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
new file mode 100644
index 0000000..535e46a
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt
@@ -0,0 +1,91 @@
+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
new file mode 100644
index 0000000..4bf7985
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt
@@ -0,0 +1,180 @@
+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
new file mode 100644
index 0000000..8b2753c
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt
@@ -0,0 +1,33 @@
+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
new file mode 100644
index 0000000..4eb9898
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt
@@ -0,0 +1,317 @@
+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
new file mode 100644
index 0000000..30b421f
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt
@@ -0,0 +1,178 @@
+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
new file mode 100644
index 0000000..b8a878a
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt
@@ -0,0 +1,105 @@
+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
new file mode 100644
index 0000000..e5615e9
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt
@@ -0,0 +1,169 @@
+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
new file mode 100644
index 0000000..40f7df0
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt
@@ -0,0 +1,32 @@
+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
new file mode 100644
index 0000000..612ae34
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt
@@ -0,0 +1,135 @@
+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)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt
new file mode 100644
index 0000000..fc4580c
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt
@@ -0,0 +1,67 @@
+package org.terst.nav.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class LogbookEntryTest {
+
+ @Test
+ fun `LogbookEntry holds all required fields`() {
+ val entry = LogbookEntry(
+ timestampMs = 1_000_000L,
+ lat = 41.39,
+ lon = -71.202,
+ sogKnots = 6.2,
+ cogDegrees = 225.0,
+ windKnots = 15.0,
+ windDirectionDeg = 225.0,
+ baroHpa = 1018.0,
+ depthMeters = 14.0,
+ event = "Departed slip",
+ notes = "Crew ready"
+ )
+ assertEquals(1_000_000L, entry.timestampMs)
+ assertEquals(41.39, entry.lat, 1e-9)
+ assertEquals(-71.202, entry.lon, 1e-9)
+ assertEquals(6.2, entry.sogKnots, 1e-9)
+ assertEquals(225.0, entry.cogDegrees, 1e-9)
+ assertEquals(15.0, entry.windKnots)
+ assertEquals(225.0, entry.windDirectionDeg)
+ assertEquals(1018.0, entry.baroHpa)
+ assertEquals(14.0, entry.depthMeters)
+ assertEquals("Departed slip", entry.event)
+ assertEquals("Crew ready", entry.notes)
+ }
+
+ @Test
+ fun `LogbookEntry optional fields default to null`() {
+ val entry = LogbookEntry(
+ timestampMs = 0L,
+ lat = 0.0,
+ lon = 0.0,
+ sogKnots = 0.0,
+ cogDegrees = 0.0
+ )
+ assertNull(entry.windKnots)
+ assertNull(entry.windDirectionDeg)
+ assertNull(entry.baroHpa)
+ assertNull(entry.depthMeters)
+ assertNull(entry.event)
+ assertNull(entry.notes)
+ }
+
+ @Test
+ fun `LogbookEntry data class equality`() {
+ val a = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0)
+ val b = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0)
+ assertEquals(a, b)
+ }
+
+ @Test
+ fun `LogbookEntry data class copy`() {
+ val original = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0, event = "anchor")
+ val copy = original.copy(sogKnots = 3.0)
+ assertEquals(3.0, copy.sogKnots, 1e-9)
+ assertEquals("anchor", copy.event)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt
new file mode 100644
index 0000000..0a6f4bb
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt
@@ -0,0 +1,56 @@
+package com.example.androidapp.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class TideModelTest {
+
+ @Test
+ fun `TideConstituent holds all fields`() {
+ val c = TideConstituent("M2", 28.9841042, 0.85, 120.0)
+ assertEquals("M2", c.name)
+ assertEquals(28.9841042, c.speedDegPerHour, 1e-7)
+ assertEquals(0.85, c.amplitudeMeters, 1e-9)
+ assertEquals(120.0, c.phaseDeg, 1e-9)
+ }
+
+ @Test
+ fun `TidePrediction holds timestamp and height`() {
+ val p = TidePrediction(1_700_000_000_000L, 2.34)
+ assertEquals(1_700_000_000_000L, p.timestampMs)
+ assertEquals(2.34, p.heightMeters, 1e-9)
+ }
+
+ @Test
+ fun `TidePrediction data class equality`() {
+ val p1 = TidePrediction(1_000L, 1.5)
+ val p2 = TidePrediction(1_000L, 1.5)
+ assertEquals(p1, p2)
+ }
+
+ @Test
+ fun `TideStation holds all fields and constituents`() {
+ val c = TideConstituent("K1", 15.0410686, 0.3, 45.0)
+ val station = TideStation(
+ id = "9447130",
+ name = "Seattle, WA",
+ lat = 47.602,
+ lon = -122.339,
+ datumOffsetMeters = 1.8,
+ constituents = listOf(c)
+ )
+ assertEquals("9447130", station.id)
+ assertEquals("Seattle, WA", station.name)
+ assertEquals(47.602, station.lat, 1e-9)
+ assertEquals(-122.339, station.lon, 1e-9)
+ assertEquals(1.8, station.datumOffsetMeters, 1e-9)
+ assertEquals(1, station.constituents.size)
+ assertEquals("K1", station.constituents[0].name)
+ }
+
+ @Test
+ fun `TideStation with empty constituents is valid`() {
+ val station = TideStation("test", "Test", 0.0, 0.0, 0.0, emptyList())
+ assertTrue(station.constituents.isEmpty())
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt b/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt