aboutsummaryrefslogtreecommitdiff
path: root/official_climb/aggregate_test.go
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-13 21:55:59 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-13 21:55:59 +0200
commite3d0beee5be3dd2854b0f4fc10905a4fcdc08f47 (patch)
treeec8234d3442c498ec170b6444bd2cfae5b29a476 /official_climb/aggregate_test.go
parentdb4d2b40895db41ce188549f49a6f277ba95c890 (diff)
Persist official climb attempts with climbs pages
Record one attempt row per (ride, climb, occurrence) when a ride detail loads, snapshotting distance, elevation gain, slope, cotacol and attempt timestamps; a climbs-backfill CLI command seeds all existing rides. New /climbs list page aggregates attempts (medians computed in Go, best time as min duration) with sortable columns, and /climbs/{id} shows the median scalar stats, a median normalized elevation profile (50 steps over each attempt's own length), the full-precision recorded route of the median-distance attempt on an OSM map, and every attempt with time at second precision.
Diffstat (limited to 'official_climb/aggregate_test.go')
-rw-r--r--official_climb/aggregate_test.go76
1 files changed, 76 insertions, 0 deletions
diff --git a/official_climb/aggregate_test.go b/official_climb/aggregate_test.go
new file mode 100644
index 0000000..2e051ce
--- /dev/null
+++ b/official_climb/aggregate_test.go
@@ -0,0 +1,76 @@
+package official_climb
+
+import (
+ "testing"
+ "time"
+
+ "github.com/martinlehoux/biking_home/climb"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMedianOddAndEven(t *testing.T) {
+ assert.Equal(t, 3.0, Median([]float64{5, 1, 3}))
+ assert.Equal(t, 2.5, Median([]float64{1, 2, 3, 4}))
+ assert.Equal(t, 0.0, Median(nil))
+}
+
+func TestAggregateAttemptsUsesMedians(t *testing.T) {
+ attempts := []Attempt{
+ {DistanceM: 3000, ElevationGainM: 200, SlopePercent: 6.7, Cotacol: 80, StartedAt: time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC), EndedAt: time.Date(2026, 1, 1, 8, 20, 0, 0, time.UTC)},
+ {DistanceM: 3200, ElevationGainM: 210, SlopePercent: 6.6, Cotacol: 84, StartedAt: time.Date(2026, 2, 1, 8, 0, 0, 0, time.UTC), EndedAt: time.Date(2026, 2, 1, 8, 15, 0, 0, time.UTC)},
+ {DistanceM: 6000, ElevationGainM: 900, SlopePercent: 15, Cotacol: 400, StartedAt: time.Date(2026, 3, 1, 8, 0, 0, 0, time.UTC), EndedAt: time.Date(2026, 3, 1, 8, 40, 0, 0, time.UTC)},
+ }
+ aggregate := AggregateAttempts(attempts)
+ assert.Equal(t, 3, aggregate.Attempts)
+ assert.Equal(t, 3200.0, aggregate.DistanceM)
+ assert.Equal(t, 210.0, aggregate.ElevationGainM)
+ assert.Equal(t, 6.7, aggregate.SlopePercent)
+ assert.Equal(t, 84.0, aggregate.Cotacol)
+ require.True(t, aggregate.HasBest)
+ assert.Equal(t, 15*time.Minute, aggregate.BestDuration)
+}
+
+func TestAggregateAttemptsIgnoresBrokenDurations(t *testing.T) {
+ attempts := []Attempt{
+ {DistanceM: 3000, StartedAt: time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC), EndedAt: time.Date(2026, 1, 1, 7, 0, 0, 0, time.UTC)},
+ {DistanceM: 3200, StartedAt: time.Date(2026, 2, 1, 8, 0, 0, 0, time.UTC), EndedAt: time.Date(2026, 2, 1, 8, 15, 0, 0, time.UTC)},
+ }
+ aggregate := AggregateAttempts(attempts)
+ require.True(t, aggregate.HasBest)
+ assert.Equal(t, 15*time.Minute, aggregate.BestDuration)
+}
+
+func TestAggregateAttemptsEmpty(t *testing.T) {
+ aggregate := AggregateAttempts(nil)
+ assert.Equal(t, 0, aggregate.Attempts)
+ assert.False(t, aggregate.HasBest)
+ assert.Equal(t, climb.CategoryNo, aggregate.Category)
+}
+
+func TestNormalizeProfileLinearSegment(t *testing.T) {
+ segment := climb.Climb{Route: climb.Route{
+ Distances: []float64{0, 500, 1000},
+ Elevations: []float64{100, 150, 200},
+ }}
+ samples := NormalizeProfile(segment)
+ require.Len(t, samples, profileSteps)
+ assert.InDelta(t, 100, samples[0], 0.01)
+ assert.InDelta(t, 200, samples[profileSteps-1], 0.01)
+ assert.InDelta(t, 100+100*float64(profileSteps/2)/float64(profileSteps-1), samples[profileSteps/2], 0.01)
+}
+
+func TestMedianProfileRobustToOutlier(t *testing.T) {
+ straight := make([]float64, profileSteps)
+ for i := range straight {
+ straight[i] = float64(i)
+ }
+ spiked := make([]float64, profileSteps)
+ for i := range spiked {
+ spiked[i] = float64(i) + 500
+ }
+ median := MedianProfile([][]float64{straight, spiked, straight})
+ require.Len(t, median, profileSteps)
+ assert.InDelta(t, 10, median[10], 0.01)
+ assert.InDelta(t, 49, median[49], 0.01)
+}