From e3d0beee5be3dd2854b0f4fc10905a4fcdc08f47 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 13 Sep 2026 21:55:59 +0200 Subject: 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. --- web/climbs_test.go | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 web/climbs_test.go (limited to 'web/climbs_test.go') diff --git a/web/climbs_test.go b/web/climbs_test.go new file mode 100644 index 0000000..fbaf527 --- /dev/null +++ b/web/climbs_test.go @@ -0,0 +1,97 @@ +package web + +import ( + "database/sql" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/martinlehoux/biking_home/official_climb" + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/biking_home/rideanalysis" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func recordFixtureAttempts(t *testing.T, server *Server, rideID int64, climb official_climb.OfficialClimb) { + t.Helper() + parsed, _ := testRideForClimb(t) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{climb}, official_climb.DefaultMatchPolicy()) + server.recordOfficialClimbAttempts(rideID, parsed, analysis) +} +func storeRideFixtureFile(t *testing.T, db *sql.DB, rideID int64) { + t.Helper() + parsed, _ := testRideForClimb(t) + fitPath := filepath.Join(t.TempDir(), "ride.fit") + fitFile, err := os.Create(fitPath) + require.NoError(t, err) + require.NoError(t, ride.EncodeFIT(parsed, fitFile)) + require.NoError(t, fitFile.Close()) + _, err = db.Exec("UPDATE rides SET fit_path = ? WHERE id = ?", fitPath, rideID) + require.NoError(t, err) +} + +func TestHandleClimbRendersAttemptsAndProfile(t *testing.T) { + server, db := newWebTestServer(t) + _, climbFixture := testRideForClimb(t) + created, err := official_climb.Create(db, climbFixture) + require.NoError(t, err) + rideID := insertTestRide(t, db) + storeRideFixtureFile(t, db, rideID) + recordFixtureAttempts(t, server, rideID, created) + + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, climbDetailURL(created.ID), nil)) + require.Equal(t, http.StatusOK, recorder.Code) + body := recorder.Body.String() + assert.Contains(t, body, "Col de Test") + assert.Contains(t, body, "climb-profile-data") + assert.Contains(t, body, "Test Ride") +} + +func TestHandleClimbNotFound(t *testing.T) { + server, _ := newWebTestServer(t) + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/climbs/999", nil)) + assert.Equal(t, http.StatusNotFound, recorder.Code) +} + +func TestBuildClimbProfileMediansAcrossRides(t *testing.T) { + server, db := newWebTestServer(t) + _, climbFixture := testRideForClimb(t) + created, err := official_climb.Create(db, climbFixture) + require.NoError(t, err) + rideID := insertTestRide(t, db) + storeRideFixtureFile(t, db, rideID) + parsed, _ := testRideForClimb(t) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) + attempts := attemptsFromAnalysis(rideID, analysis, parsed) + + profile := server.buildClimbProfile(created, official_climb.AggregateAttempts(attempts), attempts) + assert.InDelta(t, 1.0, profile.DistanceKm, 0.01) + require.Len(t, profile.Elevations, 50) + assert.InDelta(t, 300, profile.Elevations[0], 1) + assert.InDelta(t, 447, profile.Elevations[49], 2) +} + +func TestBuildClimbProfileRouteKeepsRecordedPoints(t *testing.T) { + server, db := newWebTestServer(t) + parsed, climbFixture := testRideForClimb(t) + created, err := official_climb.Create(db, climbFixture) + require.NoError(t, err) + rideID := insertTestRide(t, db) + storeRideFixtureFile(t, db, rideID) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) + attempts := attemptsFromAnalysis(rideID, analysis, parsed) + + profile := server.buildClimbProfile(created, official_climb.AggregateAttempts(attempts), attempts) + require.NotEmpty(t, profile.Route) + // Full recorded precision: every matched segment point is present, not a + // fixed 50-step resample. + assert.InDelta(t, parsed.Coords[0].Lon, profile.Route[0][0], 1e-6) + assert.InDelta(t, parsed.Coords[0].Lat, profile.Route[0][1], 1e-6) + assert.InDelta(t, parsed.Coords[1].Lon, profile.Route[1][0], 1e-6) + assert.InDelta(t, parsed.Coords[1].Lat, profile.Route[1][1], 1e-6) +} -- cgit v1.3