aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-25 13:26:22 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-25 13:26:22 +0200
commite73b7a9ccb4bc61e54d1fdc61b5954879c637d00 (patch)
tree34658a02faee971fa041a3f02160770ae283a6de
parentd5de3a6fefbc57bccfb1fe688f9c190ccf87d58d (diff)
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.
-rw-r--r--internal/ridetest/ride.go40
-rw-r--r--internal/ridetest/ride_test.go11
-rw-r--r--mountain_pass/detection_test.go52
-rw-r--r--official_climb/official_climb_test.go49
-rw-r--r--web/server_test.go22
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",