From f881a1f91f4e9f832ab987fce2b5220e100cee68 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 12 May 2026 13:13:55 -1000 Subject: Add saved tracks browser, tack detection, and map zoom-to-bounds - TackDetector: sliding-window COG algorithm (60-140deg delta = tack/jibe) with stability check to prevent mixed-window false positives; 13 tests green - TackEvent, SavedTrack: new models wrapping points + summary + detected tacks - TrackRepository.getSavedTracks(): returns List with lazy load - MapHandler: zoomToTrackBounds (LatLngBounds fit with padding), panToPoint (log step-through), updateTackLayer (yellow circle markers) - MainViewModel: savedTracks + selectedTrack StateFlows, panToPosition SharedFlow - SavedTracksFragment: RecyclerView list with date/distance/duration/tack count - TrackDetailSheet: bottom sheet with stats + event log (tacks, speed changes, start/end); tapping an entry pans the map to that position - MainActivity: zoom map on track complete, wire Saved Tracks button in instrument sheet, observe panToPosition to drive map, show tack markers when a saved track is selected Co-Authored-By: Claude Sonnet 4.6 --- .../kotlin/org/terst/nav/track/TackDetectorTest.kt | 95 ++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt (limited to 'test-runner/src/test/kotlin/org/terst') diff --git a/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt b/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt new file mode 100644 index 0000000..44bd667 --- /dev/null +++ b/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt @@ -0,0 +1,95 @@ +package org.terst.nav.track + +import org.junit.Assert.* +import org.junit.Test + +class TackDetectorTest { + + private fun pt(lat: Double, lon: Double, cog: Double, ts: Long) = + TrackPoint(lat, lon, 5.0, cog, 12.0, 0.0, true, ts) + + private fun straightTrack(cog: Double, count: Int = 10): List = + (0 until count).map { i -> pt(37.0 + i * 0.01, -122.0, cog, i * 10_000L) } + + private fun tackTrack(cogBefore: Double, cogAfter: Double, each: Int = 6): List { + val before = (0 until each).map { i -> pt(37.0 + i * 0.005, -122.0, cogBefore, i * 10_000L) } + val after = (0 until each).map { i -> pt(37.0 + each * 0.005 + i * 0.005, -122.0, cogAfter, (each + i) * 10_000L) } + return before + after + } + + @Test fun `straight course yields no tacks`() { + assertTrue(TackDetector.detectTacks(straightTrack(90.0)).isEmpty()) + } + + @Test fun `straight course on port tack yields no tacks`() { + assertTrue(TackDetector.detectTacks(straightTrack(330.0)).isEmpty()) + } + + @Test fun `detects port to starboard tack`() { + val tacks = TackDetector.detectTacks(tackTrack(330.0, 70.0)) + assertEquals(1, tacks.size) + assertTrue("cogBefore should be near 330", tacks[0].cogBefore in 325.0..335.0) + assertTrue("cogAfter should be near 70", tacks[0].cogAfter in 65.0..75.0) + } + + @Test fun `detects starboard to port tack`() { + val tacks = TackDetector.detectTacks(tackTrack(70.0, 330.0)) + assertEquals(1, tacks.size) + } + + @Test fun `detects jibe on broad reach`() { + // broad reach port → starboard: 150° → 210°, delta = 60° + val tacks = TackDetector.detectTacks(tackTrack(150.0, 250.0)) + assertEquals(1, tacks.size) + } + + @Test fun `handles 0-360 wrap`() { + // 350° → 80°: delta = 90° through north + val tacks = TackDetector.detectTacks(tackTrack(350.0, 80.0)) + assertEquals(1, tacks.size) + } + + @Test fun `gradual course change is not a tack`() { + // COG increases by 5° per point: window delta ≈ 15°, below threshold + val points = (0 until 20).map { i -> pt(37.0 + i * 0.01, -122.0, i * 5.0, i * 10_000L) } + assertTrue(TackDetector.detectTacks(points).isEmpty()) + } + + @Test fun `small heading change below threshold is ignored`() { + // 45° change — not a tack + assertTrue(TackDetector.detectTacks(tackTrack(90.0, 135.0)).isEmpty()) + } + + @Test fun `very large heading change above threshold is ignored`() { + // 160° delta — U-turn, not a normal tack or jibe + assertTrue(TackDetector.detectTacks(tackTrack(90.0, 250.0)).isEmpty()) + } + + @Test fun `detects two tacks in sequence`() { + val leg1 = (0 until 6).map { i -> pt(37.0 + i * 0.01, -122.0, 330.0, i * 10_000L) } + val leg2 = (0 until 6).map { i -> pt(37.06 + i * 0.01, -122.0, 70.0, (6 + i) * 10_000L) } + val leg3 = (0 until 6).map { i -> pt(37.12 + i * 0.01, -122.0, 330.0, (12 + i) * 10_000L) } + val tacks = TackDetector.detectTacks(leg1 + leg2 + leg3) + assertEquals(2, tacks.size) + } + + @Test fun `deduplicates tacks at same transition`() { + val before = (0 until 8).map { i -> pt(37.0, -122.0 + i * 0.0001, 330.0, i * 5_000L) } + val after = (0 until 8).map { i -> pt(37.0, -122.0 + 0.0008 + i * 0.0001, 70.0, (8 + i) * 5_000L) } + val tacks = TackDetector.detectTacks(before + after) + assertEquals(1, tacks.size) + } + + @Test fun `tack event contains correct position`() { + val tacks = TackDetector.detectTacks(tackTrack(330.0, 70.0)) + assertEquals(1, tacks.size) + // Position should be somewhere in the track bounds + assertTrue(tacks[0].lat in 37.0..37.1) + assertTrue(tacks[0].lon in -122.1..-122.0) + } + + @Test fun `too few points returns empty`() { + val points = (0 until 3).map { i -> pt(37.0, -122.0, 90.0, i * 1000L) } + assertTrue(TackDetector.detectTacks(points).isEmpty()) + } +} -- cgit v1.2.3