From 87e29e1eb5a113fbb40d452a193219a40780c302 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Tue, 25 Aug 2026 13:17:20 +0200 Subject: Describe test rides as plain strings with a ridetest DSL Add internal/ridetest parsing "2km at 7% | 1km at -7%" sections into a ride.Ride, with Sampling, StartCoord, StartTime and StartElevation options, plus per-section hr/cad/w metrics. Replace the package-private RideBuilder in climb/difficulty tests and export FromColumnsWithMetrics so metric columns can be built outside the ride package. --- ride/climb_test.go | 68 +++++-------------------------------------------- ride/difficulty_test.go | 17 +++++++------ ride/ride.go | 7 +++++ 3 files changed, 23 insertions(+), 69 deletions(-) (limited to 'ride') diff --git a/ride/climb_test.go b/ride/climb_test.go index 6b5c097..3632da7 100644 --- a/ride/climb_test.go +++ b/ride/climb_test.go @@ -1,72 +1,18 @@ package ride_test import ( - "strconv" - "strings" "testing" - "time" - "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/internal/ridetest" "github.com/martinlehoux/biking_home/ride" - "github.com/martinlehoux/kagamigo/kcore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -type RideBuilderSection struct { - slope float64 - distance float64 -} - -type RideBuilder struct { - precision float64 - sections []RideBuilderSection -} - -// Example: 13.4km at 7.5% -func (b RideBuilder) WithSection(input string) RideBuilder { - inputs := strings.Split(input, " ") - kcore.Assert(strings.HasSuffix(inputs[0], "km"), "wrong unit") - distanceKm, err := strconv.ParseFloat(strings.TrimSuffix(inputs[0], "km"), 64) - kcore.Expect(err, "faile to parse kms") - kcore.Assert(strings.HasSuffix(inputs[2], "%"), "wrong unit") - slopePercent, err := strconv.ParseFloat(strings.TrimSuffix(inputs[2], "%"), 64) - kcore.Expect(err, "failed to parse %") - - b.sections = append(b.sections, RideBuilderSection{ - slope: slopePercent / 100, - distance: distanceKm * 1000, - }) - - return b -} - -func (b RideBuilder) Build() ride.Ride { - distances := []float64{0} - elevations := []float64{0} - coords := []geodist.Coord{{}} - timestamps := []time.Time{{}} - distance := 0.0 - elevation := 0.0 - for _, section := range b.sections { - curSecDist := 0.0 - for curSecDist < section.distance { - curSecDist += b.precision - elevation += b.precision * section.slope - distances = append(distances, distance+curSecDist) - elevations = append(elevations, elevation) - coords = append(coords, geodist.Coord{}) - timestamps = append(timestamps, timestamps[len(timestamps)-1].Add(time.Second)) - } - distance += curSecDist - } - return ride.FromColumns(distances, elevations, coords, timestamps) -} - var parser = ride.GPXRideParser{} func TestClimbThenFalseFlat(t *testing.T) { - r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("10km at 1%").Build() + r := ridetest.MustRide("2km at 7% | 10km at 1%") climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum) assert.Len(t, climbs, 1) @@ -74,7 +20,7 @@ func TestClimbThenFalseFlat(t *testing.T) { } func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) { - r := RideBuilder{precision: 100}.WithSection("10km at 1%").WithSection("2km at 7%").WithSection("1km at -7%").WithSection("10km at 1%").Build() + r := ridetest.MustRide("10km at 1% | 2km at 7% | 1km at -7% | 10km at 1%") climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum) assert.Len(t, climbs, 1) @@ -147,7 +93,7 @@ func TestAlpesVerdonTour20230617(t *testing.T) { func TestDetectClimbsSkipsMiddleClaim(t *testing.T) { // A climb, a false flat giving a saddle, then a climb again. Claiming the // saddle's descent base should leave both climbs detected separately. - r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("1km at -7%").WithSection("2km at 7%").Build() + r := ridetest.MustRide("2km at 7% | 1km at -7% | 2km at 7%") climbs := r.DetectClimbs([]ride.Range{{Start: 20, End: 20}}, ride.ClimbSlopeMinimum) // No free region falls under the min distance except the two climbs. @@ -158,13 +104,13 @@ func TestDetectClimbsSkipsMiddleClaim(t *testing.T) { func TestDetectClimbsOverlappingClaimDropsClimb(t *testing.T) { // Claiming the ascent region entirely swallows the climb. - r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("20km at 0%").Build() + r := ridetest.MustRide("2km at 7% | 20km at 0%") climbs := r.DetectClimbs([]ride.Range{{Start: 0, End: 40}}, ride.ClimbSlopeMinimum) assert.Empty(t, climbs) } func TestDetectClimbsIgnoresOutOfRangeClaim(t *testing.T) { - r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("20km at 0%").Build() + r := ridetest.MustRide("2km at 7% | 20km at 0%") climbs := r.DetectClimbs([]ride.Range{{Start: -5, End: -1}}, ride.ClimbSlopeMinimum) require.Len(t, climbs, 1) assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String()) @@ -176,7 +122,7 @@ func TestDetectClimbsWithUnsortedClaimsDropsOverlappingClimb(t *testing.T) { // 2140-2627 before Lauriers 1215-1588) caused freeRegions to skip the // earlier claim and leave the overlapping Cat 4 1352-1601 visible. // Minimal reproduction: two climbs 0-2km and 3-5km separated by a descent. - r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("1km at -7%").WithSection("2km at 7%").Build() + r := ridetest.MustRide("2km at 7% | 1km at -7% | 2km at 7%") // Both climbs claimed, but in reverse order (mimics ORDER BY id returning 30-50 before 0-20). // Sorted input correctly yields 0 detected climbs. sorted := []ride.Range{{Start: 0, End: 20}, {Start: 30, End: 50}} diff --git a/ride/difficulty_test.go b/ride/difficulty_test.go index 2e7cf56..bd316b4 100644 --- a/ride/difficulty_test.go +++ b/ride/difficulty_test.go @@ -4,35 +4,36 @@ import ( "math" "testing" + "github.com/martinlehoux/biking_home/internal/ridetest" "github.com/martinlehoux/biking_home/ride" "github.com/stretchr/testify/assert" ) func TestCotacolConstantClimb(t *testing.T) { - r := RideBuilder{precision: 100}.WithSection("2km at 7%").Build() + r := ridetest.MustRide("2km at 7%") assert.InDelta(t, 98.0, ride.Cotacol(r), 1e-9) } func TestCotacolIgnoresDescent(t *testing.T) { - r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("2km at -7%").Build() + r := ridetest.MustRide("2km at 7% | 2km at -7%") assert.InDelta(t, 98.0, ride.Cotacol(r), 1e-9) } func TestCotacolFlatIsZero(t *testing.T) { - r := RideBuilder{precision: 100}.WithSection("5km at 0%").Build() + r := ridetest.MustRide("5km at 0%") assert.Zero(t, ride.Cotacol(r)) } func TestCotacolSteepShortCountsMore(t *testing.T) { - steep := RideBuilder{precision: 100}.WithSection("0.5km at 14%").Build() - long := RideBuilder{precision: 100}.WithSection("1km at 7%").Build() + steep := ridetest.MustRide("0.5km at 14%") + long := ridetest.MustRide("1km at 7%") assert.InDelta(t, 98.0, ride.Cotacol(steep), 1e-9) assert.InDelta(t, 49.0, ride.Cotacol(long), 1e-9) } func TestCotacolPrecisionInvariant(t *testing.T) { - coarse := RideBuilder{precision: 100}.WithSection("3km at 5%").Build() - fine := RideBuilder{precision: 50}.WithSection("3km at 5%").Build() + coarse := ridetest.MustRide("3km at 5%") + fine := ridetest.MustRide("3km at 5%", ridetest.Sampling(50)) assert.InDelta(t, ride.Cotacol(coarse), ride.Cotacol(fine), 1e-9) } @@ -43,7 +44,7 @@ func TestCotacolExampleRide(t *testing.T) { } func TestCotacolClimbNotAtStart(t *testing.T) { - r := RideBuilder{precision: 100}.WithSection("1km at 0%").WithSection("2km at 7%").Build() + r := ridetest.MustRide("1km at 0% | 2km at 7%") climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum) assert.Len(t, climbs, 1) assert.InDelta(t, 98.0, climbs[0].DifficultyScore(), 1e-9) diff --git a/ride/ride.go b/ride/ride.go index 1de8b07..7863878 100644 --- a/ride/ride.go +++ b/ride/ride.go @@ -161,6 +161,13 @@ func FromColumns(distances []float64, elevations []float64, coords []geodist.Coo return fromColumns(distances, elevations, coords, timestamps, nil, nil, nil) } +// FromColumnsWithMetrics builds a Ride from its columns, including optional +// heart-rate, cadence and power components of the same length as the other +// columns. +func FromColumnsWithMetrics(distances []float64, elevations []float64, coords []geodist.Coord, timestamps []time.Time, heartRates, cadences, powers []float64) Ride { + return fromColumns(distances, elevations, coords, timestamps, heartRates, cadences, powers) +} + func fromColumns(distances []float64, elevations []float64, coords []geodist.Coord, timestamps []time.Time, heartRates, cadences, powers []float64) Ride { ride := Ride{ distances: distances, -- cgit v1.3