From e73b7a9ccb4bc61e54d1fdc61b5954879c637d00 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Tue, 25 Aug 2026 13:26:22 +0200 Subject: Migrate cross-package ride test fixtures to the ridetest DSL Replace raw FromColumns 4-array blocks in official_climb, mountain_pass and web test rides with ridetest.MustRide strings. Add a Heading option to ridetest for routes whose points are spaced differently per kilometer (e.g. the mountain_pass expanded-bounds route at 0.1 degrees/km). Lap, direction and cluster tests in official_climb keep FromColumns: the DSL generates monotonic northeast coords and cannot express per-point loops or reversals. --- internal/ridetest/ride.go | 40 +++++++++++++++++++++------ internal/ridetest/ride_test.go | 11 ++++++++ mountain_pass/detection_test.go | 52 ++++++++++++++--------------------- official_climb/official_climb_test.go | 49 ++++----------------------------- web/server_test.go | 22 +++------------ 5 files changed, 72 insertions(+), 102 deletions(-) diff --git a/internal/ridetest/ride.go b/internal/ridetest/ride.go index a6413a9..e74ce49 100644 --- a/internal/ridetest/ride.go +++ b/internal/ridetest/ride.go @@ -6,9 +6,10 @@ // ridetest.MustRide("2km at 7% | 1km at -7%") // // Options adjust the generated columns: Sampling (step in meters, default -// 100), StartCoord (first point; later points progress northeast by 0.01° of -// latitude and longitude per kilometer), StartTime (default the zero time, -// one second between points) and StartElevation (default 0). +// 100), StartCoord (first point; later points progress northeast at the +// Heading rate, default 0.01° of latitude and longitude per kilometer), +// StartTime (default the zero time, one second between points) and +// StartElevation (default 0). package ridetest import ( @@ -46,6 +47,8 @@ type point struct { type options struct { samplingM float64 startCoord *geodist.Coord + headingLat float64 + headingLon float64 startTime time.Time startElevM float64 } @@ -60,9 +63,28 @@ func Sampling(m float64) Option { } // StartCoord sets the coordinate of the first point. Each following point -// progresses northeast by 0.01° of latitude and longitude per kilometer. +// progresses northeast at the rate set by Heading, default 0.01° of latitude +// and longitude per kilometer. func StartCoord(lat, lon float64) Option { - return func(o *options) { o.startCoord = &geodist.Coord{Lat: lat, Lon: lon} } + return func(o *options) { + o.startCoord = &geodist.Coord{Lat: lat, Lon: lon} + if o.headingLat == 0 { + o.headingLat = headingPerKm + } + if o.headingLon == 0 { + o.headingLon = headingPerKm + } + } +} + +// Heading sets the latitude and longitude offset applied to each point per +// kilometer of ride, in degrees (default 0.01 northeast when StartCoord is +// set). Only meaningful together with StartCoord. +func Heading(latPerKm, lonPerKm float64) Option { + return func(o *options) { + o.headingLat = latPerKm + o.headingLon = lonPerKm + } } // StartTime sets the timestamp of the first point; each following point is @@ -80,7 +102,7 @@ func StartElevation(m float64) Option { // // Example: "2km at 7% | 1km at -7%". func Ride(dsl string, opts ...Option) (ride.Ride, error) { - o := options{samplingM: defaultSamplingM} + o := options{samplingM: defaultSamplingM, headingLat: headingPerKm, headingLon: headingPerKm} for _, opt := range opts { opt(&o) } @@ -250,10 +272,10 @@ func buildPoints(sections []section, o options) []point { lat, lon = o.startCoord.Lat, o.startCoord.Lon } // Coordinates derive from the total distance so that exact multiples of - // the per-kilometer heading (0.01° x km as float64) stay exact; a running - // accumulation drifts after a few points. + // the per-kilometer heading stay exact; a running accumulation drifts + // after a few points. coord := func(distanceM float64) geodist.Coord { - return geodist.Coord{Lat: lat + headingPerKm*(distanceM/1000), Lon: lon + headingPerKm*(distanceM/1000)} + return geodist.Coord{Lat: lat + o.headingLat*(distanceM/1000), Lon: lon + o.headingLon*(distanceM/1000)} } points := make([]point, 0, len(sections)+1) elevation := o.startElevM diff --git a/internal/ridetest/ride_test.go b/internal/ridetest/ride_test.go index 69c084d..65483f2 100644 --- a/internal/ridetest/ride_test.go +++ b/internal/ridetest/ride_test.go @@ -53,6 +53,17 @@ func TestRideStartCoordHeading(t *testing.T) { assert.InDelta(t, 5.12, r.Coords()[2].Lon, 1e-9) } +func TestRideHeadingOption(t *testing.T) { + r, err := Ride("1km at 0%", StartCoord(43.0, 5.0), Heading(0.1, 0.1), Sampling(1000)) + require.NoError(t, err) + + // A single kilometer at 0.1°/km ends at (43.1, 5.1), matching rides whose + // points are spaced 0.1° apart over a kilometer. + require.Len(t, r.Coords(), 2) + assert.InDelta(t, 43.1, r.Coords()[1].Lat, 1e-9) + assert.InDelta(t, 5.1, r.Coords()[1].Lon, 1e-9) +} + func TestRideStartTime(t *testing.T) { start := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) r, err := Ride("1km at 0%", StartTime(start), Sampling(500)) diff --git a/mountain_pass/detection_test.go b/mountain_pass/detection_test.go index 39bb9ae..6318ca3 100644 --- a/mountain_pass/detection_test.go +++ b/mountain_pass/detection_test.go @@ -1,11 +1,12 @@ package mountain_pass_test import ( + "fmt" "testing" - "time" "github.com/jftuga/geodist" "github.com/martinlehoux/biking_home/internal/dbtest" + "github.com/martinlehoux/biking_home/internal/ridetest" "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/ride" "github.com/stretchr/testify/assert" @@ -40,12 +41,7 @@ func TestLoadMountainPassesAroundRideUsesExpandedBounds(t *testing.T) { ) require.NoError(t, err) - route := ride.FromColumns( - []float64{0, 1000}, - []float64{100, 200}, - []geodist.Coord{{Lat: 43.0, Lon: 5.0}, {Lat: 43.1, Lon: 5.1}}, - make([]time.Time, 2), - ) + route := ridetest.MustRide("1km at 10%", ridetest.StartCoord(43.0, 5.0), ridetest.Heading(0.1, 0.1), ridetest.StartElevation(100), ridetest.Sampling(1000)) passes, err := mountain_pass.LoadMountainPassesAroundRide(db, route.Coords(), 10_000) require.NoError(t, err) require.Len(t, passes, 3) @@ -99,22 +95,19 @@ func TestDetectCrossingsRequiresElevationAgreement(t *testing.T) { assert.Empty(t, mountain_pass.DetectCrossings(r.Coords(), r.Distances(), r.Elevations(), []mountain_pass.MountainPass{pass}, 100, 25)) } +// rideFromPoint builds a 1 km ride ending at (latitude, longitude) at the +// given elevation, starting 0.01° southwest at 100 m. func rideFromPoint(t *testing.T, latitude, longitude, elevation float64) ride.Ride { t.Helper() - return ride.FromColumns( - []float64{0, 1000}, - []float64{100, elevation}, - []geodist.Coord{{Lat: latitude - 0.01, Lon: longitude - 0.01}, {Lat: latitude, Lon: longitude}}, - make([]time.Time, 2), - ) + slopePercent := (elevation - 100) / 10 // gain over a 1 km ride, as a percentage + return ridetest.MustRide(fmt.Sprintf("1km at %v%%", slopePercent), + ridetest.StartCoord(latitude-0.01, longitude-0.01), + ridetest.StartElevation(100), + ridetest.Sampling(1000)) } func TestMatchClimbFindsPassAtTop(t *testing.T) { - climb := climbFromColumns( - []float64{0, 1000, 2000}, - []float64{300, 447, 380}, - []geodist.Coord{{Lat: 43.61, Lon: 5.42}, {Lat: 43.62, Lon: 5.43}, {Lat: 43.63, Lon: 5.44}}, - ) + climb := climbFixture() pass := mountain_pass.MountainPass{ Name: "Pas de Magnan", Elevation: 440, Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43}, @@ -125,11 +118,7 @@ func TestMatchClimbFindsPassAtTop(t *testing.T) { } func TestMatchClimbRequiresElevationAgreement(t *testing.T) { - climb := climbFromColumns( - []float64{0, 1000, 2000}, - []float64{300, 447, 380}, - []geodist.Coord{{Lat: 43.61, Lon: 5.42}, {Lat: 43.62, Lon: 5.43}, {Lat: 43.63, Lon: 5.44}}, - ) + climb := climbFixture() pass := mountain_pass.MountainPass{ Name: "Pas de Magnan", Elevation: 900, Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43}, @@ -139,11 +128,7 @@ func TestMatchClimbRequiresElevationAgreement(t *testing.T) { } func TestMatchClimbNearestOfTwo(t *testing.T) { - climb := climbFromColumns( - []float64{0, 1000, 2000}, - []float64{300, 447, 380}, - []geodist.Coord{{Lat: 43.61, Lon: 5.42}, {Lat: 43.62, Lon: 5.43}, {Lat: 43.63, Lon: 5.44}}, - ) + climb := climbFixture() near := mountain_pass.MountainPass{ Name: "Pas de Magnan", Elevation: 440, Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43}, @@ -157,7 +142,12 @@ func TestMatchClimbNearestOfTwo(t *testing.T) { assert.Equal(t, "Pas de Magnan", matched.Name) } -func climbFromColumns(distances, elevations []float64, coords []geodist.Coord) ride.Climb { - ride := ride.FromColumns(distances, elevations, coords, make([]time.Time, len(distances))) - return ride.ClimbFromDist(0, 2000) +// climbFixture builds a 2 km climb from 300 m up to 447 m then back down to +// 380 m, heading northeast from (43.61, 5.42). +func climbFixture() ride.Climb { + r := ridetest.MustRide("1km at 14.7% | 1km at -6.7%", + ridetest.StartCoord(43.61, 5.42), + ridetest.StartElevation(300), + ridetest.Sampling(1000)) + return r.ClimbFromDist(0, 2000) } diff --git a/official_climb/official_climb_test.go b/official_climb/official_climb_test.go index bd59efc..ed121e5 100644 --- a/official_climb/official_climb_test.go +++ b/official_climb/official_climb_test.go @@ -6,6 +6,7 @@ import ( "github.com/jftuga/geodist" "github.com/martinlehoux/biking_home/internal/dbtest" + "github.com/martinlehoux/biking_home/internal/ridetest" "github.com/martinlehoux/biking_home/official_climb" "github.com/martinlehoux/biking_home/ride" "github.com/stretchr/testify/assert" @@ -52,17 +53,7 @@ func TestMatchPolicyRejectsInvalidRadius(t *testing.T) { } func TestMatchClimbMatchesWithoutDetectedClimb(t *testing.T) { - parsed := ride.FromColumns( - []float64{0, 1000, 2000, 3000}, - []float64{100, 150, 200, 100}, - []geodist.Coord{ - {Lat: 43.10, Lon: 5.10}, - {Lat: 43.11, Lon: 5.11}, - {Lat: 43.12, Lon: 5.12}, - {Lat: 43.13, Lon: 5.13}, - }, - []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0)}, - ) + parsed := ridetest.MustRide("2km at 5% | 1km at -10%", ridetest.StartCoord(43.10, 5.10), ridetest.StartElevation(100), ridetest.Sampling(1000)) official := official_climb.OfficialClimb{ Name: "On route", StartCoord: parsed.Coords()[1], @@ -75,17 +66,7 @@ func TestMatchClimbMatchesWithoutDetectedClimb(t *testing.T) { } func TestMatchClimbMatchesEndpointInsideRadius(t *testing.T) { - parsed := ride.FromColumns( - []float64{0, 1000, 2000, 3000}, - []float64{100, 150, 200, 100}, - []geodist.Coord{ - {Lat: 43.10, Lon: 5.10}, - {Lat: 43.11, Lon: 5.11}, - {Lat: 43.12, Lon: 5.12}, - {Lat: 43.13, Lon: 5.13}, - }, - []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0)}, - ) + parsed := ridetest.MustRide("2km at 5% | 1km at -10%", ridetest.StartCoord(43.10, 5.10), ridetest.StartElevation(100), ridetest.Sampling(1000)) official := official_climb.OfficialClimb{ Name: "Offset from route", StartCoord: geodist.Coord{Lat: 43.1101, Lon: 5.1101}, @@ -98,17 +79,7 @@ func TestMatchClimbMatchesEndpointInsideRadius(t *testing.T) { } func TestMatchClimbRejectsReversedEndpoints(t *testing.T) { - parsed := ride.FromColumns( - []float64{0, 1000, 2000, 3000}, - []float64{100, 150, 200, 100}, - []geodist.Coord{ - {Lat: 43.10, Lon: 5.10}, - {Lat: 43.11, Lon: 5.11}, - {Lat: 43.12, Lon: 5.12}, - {Lat: 43.13, Lon: 5.13}, - }, - []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0)}, - ) + parsed := ridetest.MustRide("2km at 5% | 1km at -10%", ridetest.StartCoord(43.10, 5.10), ridetest.StartElevation(100), ridetest.Sampling(1000)) official := official_climb.OfficialClimb{ Name: "Reversed", StartCoord: parsed.Coords()[2], @@ -119,17 +90,7 @@ func TestMatchClimbRejectsReversedEndpoints(t *testing.T) { } func TestMatchClimbRejectsEndpointOutsideRadius(t *testing.T) { - parsed := ride.FromColumns( - []float64{0, 1000, 2000, 3000}, - []float64{100, 150, 200, 100}, - []geodist.Coord{ - {Lat: 43.10, Lon: 5.10}, - {Lat: 43.11, Lon: 5.11}, - {Lat: 43.12, Lon: 5.12}, - {Lat: 43.13, Lon: 5.13}, - }, - []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0)}, - ) + parsed := ridetest.MustRide("2km at 5% | 1km at -10%", ridetest.StartCoord(43.10, 5.10), ridetest.StartElevation(100), ridetest.Sampling(1000)) official := official_climb.OfficialClimb{ Name: "Too far", StartCoord: geodist.Coord{Lat: 43.09, Lon: 5.09}, diff --git a/web/server_test.go b/web/server_test.go index 9ee578e..6a1c13f 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -17,6 +17,7 @@ import ( "github.com/jftuga/geodist" "github.com/martinlehoux/biking_home/config" "github.com/martinlehoux/biking_home/internal/dbtest" + "github.com/martinlehoux/biking_home/internal/ridetest" "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/official_climb" "github.com/martinlehoux/biking_home/ride" @@ -197,12 +198,7 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { } func TestBuildRideProfileIncludesClimbsAndCrossings(t *testing.T) { - parsed := ride.FromColumns( - []float64{0, 1000, 2000}, - []float64{300, 447, 380}, - []geodist.Coord{{Lat: 43.61, Lon: 5.42}, {Lat: 43.62, Lon: 5.43}, {Lat: 43.63, Lon: 5.44}}, - make([]time.Time, 3), - ) + parsed := ridetest.MustRide("1km at 14.7% | 1km at -6.7%", ridetest.StartCoord(43.61, 5.42), ridetest.StartElevation(300), ridetest.Sampling(1000)) passes := []mountain_pass.MountainPass{{ Name: "Pas de Magnan", Elevation: 440, @@ -225,12 +221,7 @@ func TestBuildRideProfileIncludesClimbsAndCrossings(t *testing.T) { } func TestBuildRideProfileIncludesOfficialClimbMatch(t *testing.T) { - parsed := ride.FromColumns( - []float64{0, 1000, 2000}, - []float64{300, 447, 380}, - []geodist.Coord{{Lat: 43.61, Lon: 5.42}, {Lat: 43.62, Lon: 5.43}, {Lat: 43.63, Lon: 5.44}}, - make([]time.Time, 3), - ) + parsed := ridetest.MustRide("1km at 14.7% | 1km at -6.7%", ridetest.StartCoord(43.61, 5.42), ridetest.StartElevation(300), ridetest.Sampling(1000)) profile := buildRideProfile(parsed, nil, []official_climb.OfficialClimb{{ ID: 42, Name: "Col de Test", @@ -247,12 +238,7 @@ func TestBuildRideProfileIncludesOfficialClimbMatch(t *testing.T) { } func TestBuildRideProfileUsesOfficialClimbBoundaries(t *testing.T) { - parsed := ride.FromColumns( - []float64{0, 1000, 2000}, - []float64{300, 447, 380}, - []geodist.Coord{{Lat: 43.61, Lon: 5.42}, {Lat: 43.62, Lon: 5.43}, {Lat: 43.6205, Lon: 5.4305}}, - make([]time.Time, 3), - ) + parsed := ridetest.MustRide("1km at 14.7% | 1km at -6.7%", ridetest.StartCoord(43.61, 5.42), ridetest.StartElevation(300), ridetest.Sampling(1000)) profile := buildRideProfile(parsed, nil, []official_climb.OfficialClimb{{ ID: 42, Name: "Col de Test", -- cgit v1.3