diff options
| author | Claude <noreply@anthropic.com> | 2026-05-27 15:37:29 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-05-27 15:37:29 +0000 |
| commit | b18f2c94e7dd171c1d8209158237f88bea43f051 (patch) | |
| tree | cf4bb701a01e4a2bb6aee448aa6cfe7ac9e2f90d /android-app/app/src | |
| parent | 4f87ae6cda6131012447a8b2ddde088bb8844b04 (diff) | |
Add unit tests for PreTripReportGenerator
Covers estimatedSogKt (hull-speed cap, point-of-sail ordering, light-air,
hull-length scaling, overpowered vs sweet-spot) and generateReport
(condition window size, Now label, heading range, outbound nm, sail plan
logic, watch items, similar trips).
https://claude.ai/code/session_01KXYBuAHZkvv63DeUG6XaZD
Diffstat (limited to 'android-app/app/src')
| -rw-r--r-- | android-app/app/src/test/kotlin/org/terst/nav/tripreport/PreTripReportGeneratorTest.kt | 173 |
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) + } +} |
