summaryrefslogtreecommitdiff
path: root/android-app/app/src/test
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/test')
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/tripreport/PreTripReportGeneratorTest.kt173
1 files changed, 173 insertions, 0 deletions
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/tripreport/PreTripReportGeneratorTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/tripreport/PreTripReportGeneratorTest.kt
new file mode 100644
index 0000000..adf4c96
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/tripreport/PreTripReportGeneratorTest.kt
@@ -0,0 +1,173 @@
+package org.terst.nav.tripreport
+
+import org.junit.Assert.*
+import org.junit.Test
+import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.MarineConditions
+import kotlin.math.sqrt
+
+class PreTripReportGeneratorTest {
+
+ private val gen = PreTripReportGenerator()
+
+ private fun forecast(
+ timeIso: String = "2026-01-01T12:00",
+ windKt: Double = 12.0,
+ windDir: Double = 90.0,
+ precipPct: Int = 0,
+ weatherCode: Int = 0
+ ) = ForecastItem(timeIso, windKt, windDir, 22.0, precipPct, weatherCode)
+
+ private val defaultBoat = BoatProfile(
+ id = "test", name = "Delos", lengthFt = 36.0,
+ type = BoatType.MONOHULL, rig = RigType.SLOOP,
+ headsails = listOf(
+ SailConfig("155% Genoa", maxWindKt = 15, isHeadsail = true),
+ SailConfig("100% Jib", maxWindKt = 25, isHeadsail = true)
+ ),
+ mainReefs = 2
+ )
+
+ private val defaultForecasts = (0 until 10).map { i ->
+ forecast(timeIso = "2026-01-01T${String.format("%02d", 12 + i)}:00")
+ }
+
+ // ── estimatedSogKt ─────────────────────────────────────────────────────────
+
+ @Test fun `estimatedSogKt never exceeds hull speed`() {
+ val hull = 1.34 * sqrt(36.0)
+ val sog = gen.estimatedSogKt(36.0, 25.0, 90.0)
+ assertTrue("SOG $sog should not exceed hull speed $hull", sog <= hull + 0.001)
+ }
+
+ @Test fun `estimatedSogKt beam reach faster than dead run`() {
+ val beamReach = gen.estimatedSogKt(36.0, 15.0, 90.0)
+ val deadRun = gen.estimatedSogKt(36.0, 15.0, 175.0)
+ assertTrue("Beam reach $beamReach should be faster than dead run $deadRun", beamReach > deadRun)
+ }
+
+ @Test fun `estimatedSogKt close hauled slower than beam reach`() {
+ val closeHauled = gen.estimatedSogKt(36.0, 15.0, 30.0)
+ val beamReach = gen.estimatedSogKt(36.0, 15.0, 90.0)
+ assertTrue("Close hauled $closeHauled should be slower than beam reach $beamReach",
+ closeHauled < beamReach)
+ }
+
+ @Test fun `estimatedSogKt light air produces low sog`() {
+ val lightAir = gen.estimatedSogKt(36.0, 3.0, 90.0)
+ assertTrue("SOG $lightAir should be low in light air (< 2.0 kt)", lightAir < 2.0)
+ }
+
+ @Test fun `estimatedSogKt longer hull is faster`() {
+ val short = gen.estimatedSogKt(25.0, 15.0, 90.0)
+ val long = gen.estimatedSogKt(50.0, 15.0, 90.0)
+ assertTrue("50 ft boat ($long) should be faster than 25 ft ($short)", long > short)
+ }
+
+ @Test fun `estimatedSogKt overpowered reefed boat is slightly slower than full-sail`() {
+ val strong = gen.estimatedSogKt(36.0, 25.0, 90.0) // reefed, windFactor = 0.88
+ val sweet = gen.estimatedSogKt(36.0, 18.0, 90.0) // full sail, windFactor = 1.00
+ assertTrue("Full-sail sweet-spot ($sweet) should be ≥ overpowered-reefed ($strong)",
+ sweet >= strong)
+ }
+
+ // ── generateReport: structure ──────────────────────────────────────────────
+
+ @Test fun `condition window always has exactly 4 slots`() {
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, null, defaultBoat)
+ assertEquals(4, report.conditionWindow.size)
+ }
+
+ @Test fun `condition window first slot is Now when departure is current time`() {
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, null, defaultBoat,
+ departureDateTimeMs = System.currentTimeMillis())
+ assertEquals("Now", report.conditionWindow.first().label)
+ }
+
+ @Test fun `route projection heading is within 0 to 360`() {
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, null, defaultBoat)
+ val hdg = report.routeProjection.departureHeadingDeg
+ assertTrue("Heading $hdg should be in [0, 360]", hdg in 0.0..360.0)
+ }
+
+ @Test fun `route projection outbound nm is positive`() {
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, null, defaultBoat)
+ assertTrue("Outbound nm should be > 0", report.routeProjection.outboundLegNm > 0.0)
+ }
+
+ // ── generateReport: sail plan ──────────────────────────────────────────────
+
+ @Test fun `sail plan includes main and headsail at moderate wind`() {
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, null, defaultBoat)
+ assertTrue("Expected headsail entry", report.sailPlan.any {
+ it.sailName.contains("Genoa") || it.sailName.contains("Jib")
+ })
+ assertTrue("Expected Main entry", report.sailPlan.any { it.sailName == "Main" })
+ }
+
+ @Test fun `sail plan reefs main at 23 kt`() {
+ val strongWind = (0 until 10).map { forecast(windKt = 23.0) }
+ val report = gen.generateReport(37.0, -122.0, strongWind, null, defaultBoat)
+ val main = report.sailPlan.first { it.sailName == "Main" }
+ assertTrue("Expected reef at 23 kt: ${main.action}", main.action.contains("Reef"))
+ }
+
+ @Test fun `sail plan uses small headsail above genoa limit`() {
+ val overLimit = (0 until 10).map { forecast(windKt = 18.0) }
+ val report = gen.generateReport(37.0, -122.0, overLimit, null, defaultBoat)
+ val headsail = report.sailPlan.first {
+ it.sailName.contains("Genoa") || it.sailName.contains("Jib")
+ }
+ // At 18 kt, 155% Genoa (max 15 kt) should be swapped for 100% Jib
+ assertTrue("Expected 100% Jib at 18 kt, got: ${headsail.sailName}",
+ headsail.sailName.contains("100%") || headsail.sailName.contains("Jib"))
+ }
+
+ // ── generateReport: watch items ────────────────────────────────────────────
+
+ @Test fun `no watch items in benign conditions`() {
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, null, defaultBoat)
+ // At 12 kt with no swell data, no warnings expected
+ assertTrue("Expected no high-wind warnings at 12 kt",
+ report.watchItems.none { it.message.contains("Strong") || it.message.contains("consider staying") })
+ }
+
+ @Test fun `strong wind over 30 kt triggers stay in port advisory`() {
+ val gale = (0 until 10).map { forecast(windKt = 32.0) }
+ val report = gen.generateReport(37.0, -122.0, gale, null, defaultBoat)
+ assertTrue("Expected stay-in-port advisory at 32 kt",
+ report.watchItems.any { it.message.contains("consider staying") })
+ }
+
+ @Test fun `short swell period under 8 s triggers choppy sea warning`() {
+ val conditions = MarineConditions(
+ windSpeedKt = 12.0, windDirDeg = 90.0, tempC = 22.0,
+ waveHeightM = null, waveDirDeg = null,
+ swellHeightM = 2.0, swellDirDeg = null, swellPeriodS = 5.0,
+ currentSpeedKt = null, currentDirDeg = null
+ )
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, conditions, defaultBoat)
+ assertTrue("Expected short-swell warning",
+ report.watchItems.any { it.message.contains("period") || it.message.contains("choppy") })
+ }
+
+ @Test fun `significant swell over 2 m triggers motion advisory`() {
+ val conditions = MarineConditions(
+ windSpeedKt = 12.0, windDirDeg = 90.0, tempC = 22.0,
+ waveHeightM = null, waveDirDeg = null,
+ swellHeightM = 2.5, swellDirDeg = null, swellPeriodS = 12.0,
+ currentSpeedKt = null, currentDirDeg = null
+ )
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, conditions, defaultBoat)
+ assertTrue("Expected significant-swell advisory",
+ report.watchItems.any { it.message.contains("swell") || it.message.contains("motion") })
+ }
+
+ // ── generateReport: similar trips ─────────────────────────────────────────
+
+ @Test fun `no similar trips when past tracks is empty`() {
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, null, defaultBoat,
+ pastTracks = emptyList())
+ assertNull(report.similarTrips)
+ }
+}