diff options
| -rw-r--r-- | internal/ridetest/ride.go | 286 | ||||
| -rw-r--r-- | internal/ridetest/ride_test.go | 146 | ||||
| -rw-r--r-- | ride/climb_test.go | 68 | ||||
| -rw-r--r-- | ride/difficulty_test.go | 17 | ||||
| -rw-r--r-- | ride/ride.go | 7 |
5 files changed, 455 insertions, 69 deletions
diff --git a/internal/ridetest/ride.go b/internal/ridetest/ride.go new file mode 100644 index 0000000..a6413a9 --- /dev/null +++ b/internal/ridetest/ride.go @@ -0,0 +1,286 @@ +// Package ridetest builds ride.Ride test fixtures from a compact string DSL. +// +// Grammar: sections separated by "|", each "<distance>km at <slope>%" with +// optional per-section metrics "hr=150 cad=80 w=200": +// +// 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). +package ridetest + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/ride" +) + +const defaultSamplingM = 100 + +// headingPerKm is the latitude and longitude offset applied to each point per +// kilometer of ride, giving StartCoord rides a fixed northeast direction. +const headingPerKm = 0.01 + +type section struct { + distanceM float64 + slope float64 + heartRate *float64 + cadence *float64 + power *float64 +} + +type point struct { + distanceM float64 + elevationM float64 + coord geodist.Coord + timestamp time.Time + section section +} + +type options struct { + samplingM float64 + startCoord *geodist.Coord + startTime time.Time + startElevM float64 +} + +// Option configures the ride generated by Ride. +type Option func(*options) + +// Sampling sets the distance between consecutive points in meters +// (default 100). +func Sampling(m float64) Option { + return func(o *options) { o.samplingM = m } +} + +// StartCoord sets the coordinate of the first point. Each following point +// progresses northeast by 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} } +} + +// StartTime sets the timestamp of the first point; each following point is +// one second later (default: the zero time). +func StartTime(t time.Time) Option { + return func(o *options) { o.startTime = t } +} + +// StartElevation sets the elevation of the first point, in meters (default 0). +func StartElevation(m float64) Option { + return func(o *options) { o.startElevM = m } +} + +// Ride parses a ride description into a ride.Ride. +// +// Example: "2km at 7% | 1km at -7%". +func Ride(dsl string, opts ...Option) (ride.Ride, error) { + o := options{samplingM: defaultSamplingM} + for _, opt := range opts { + opt(&o) + } + if o.samplingM <= 0 { + return ride.Ride{}, fmt.Errorf("ridetest: sampling must be positive, got %v", o.samplingM) + } + sections, err := parseSections(dsl) + if err != nil { + return ride.Ride{}, err + } + if len(sections) == 0 { + return ride.Ride{}, fmt.Errorf("ridetest: empty ride description") + } + for _, metric := range []struct { + name string + get func(section) *float64 + }{ + {"hr", func(s section) *float64 { return s.heartRate }}, + {"cad", func(s section) *float64 { return s.cadence }}, + {"w", func(s section) *float64 { return s.power }}, + } { + present, complete := metricComplete(sections, metric.get) + if present && !complete { + return ride.Ride{}, fmt.Errorf("ridetest: %s= must be set on every section or none", metric.name) + } + } + return buildRide(sections, o), nil +} + +// MustRide is like Ride but panics on a malformed description; use it inside +// tests where the description is a static program constant. +func MustRide(dsl string, opts ...Option) ride.Ride { + ride, err := Ride(dsl, opts...) + if err != nil { + panic(err) + } + return ride +} + +func parseSections(dsl string) ([]section, error) { + rawSections := strings.Split(dsl, "|") + sections := make([]section, 0, len(rawSections)) + for _, raw := range rawSections { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("ridetest: empty section in %q", dsl) + } + sec, err := parseSection(raw) + if err != nil { + return nil, err + } + sections = append(sections, sec) + } + return sections, nil +} + +// parseSection parses a single section like "2km at 7% hr=150 cad=80 w=200". +func parseSection(raw string) (section, error) { + fields := strings.Fields(raw) + if len(fields) < 3 { + return section{}, fmt.Errorf("ridetest: section %q must look like \"2km at 7%%\"", raw) + } + if fields[1] != "at" { + return section{}, fmt.Errorf("ridetest: section %q: expected \"at\" between distance and slope, got %q", raw, fields[1]) + } + distanceKm, err := parseUnit(fields[0], "km") + if err != nil { + return section{}, fmt.Errorf("ridetest: section %q: %w", raw, err) + } + slopePercent, err := parseUnit(fields[2], "%") + if err != nil { + return section{}, fmt.Errorf("ridetest: section %q: %w", raw, err) + } + if distanceKm <= 0 { + return section{}, fmt.Errorf("ridetest: section %q: distance must be positive", raw) + } + sec := section{ + distanceM: distanceKm * 1000, + slope: slopePercent / 100, + } + for _, token := range fields[3:] { + var value *float64 + switch { + case strings.HasPrefix(token, "hr="): + value, err = parseMetric(token, "hr") + sec.heartRate = value + case strings.HasPrefix(token, "cad="): + value, err = parseMetric(token, "cad") + sec.cadence = value + case strings.HasPrefix(token, "w="): + value, err = parseMetric(token, "w") + sec.power = value + default: + return section{}, fmt.Errorf("ridetest: section %q: unknown metric %q (want hr=, cad= or w=)", raw, token) + } + if err != nil { + return section{}, fmt.Errorf("ridetest: section %q: %w", raw, err) + } + } + return sec, nil +} + +func parseUnit(token, unit string) (float64, error) { + if !strings.HasSuffix(token, unit) { + return 0, fmt.Errorf("expected a value in %s, got %q", unit, token) + } + return strconv.ParseFloat(strings.TrimSuffix(token, unit), 64) +} + +func parseMetric(token, name string) (*float64, error) { + value, err := strconv.ParseFloat(strings.TrimPrefix(token, name+"="), 64) + if err != nil { + return nil, fmt.Errorf("invalid %s value %q", name, token) + } + return &value, nil +} + +// metricComplete reports whether a metric column is present (any section sets +// it) and complete (every section sets it). +func metricComplete(sections []section, get func(section) *float64) (present, complete bool) { + set := 0 + for _, sec := range sections { + if get(sec) != nil { + set++ + } + } + return set > 0, set == len(sections) +} + +func buildRide(sections []section, o options) ride.Ride { + points := buildPoints(sections, o) + distances := make([]float64, len(points)) + elevations := make([]float64, len(points)) + coords := make([]geodist.Coord, len(points)) + timestamps := make([]time.Time, len(points)) + var heartRates, cadences, powers []float64 + if sections[0].heartRate != nil { + heartRates = make([]float64, len(points)) + } + if sections[0].cadence != nil { + cadences = make([]float64, len(points)) + } + if sections[0].power != nil { + powers = make([]float64, len(points)) + } + for i, p := range points { + distances[i] = p.distanceM + elevations[i] = p.elevationM + coords[i] = p.coord + timestamps[i] = p.timestamp + if heartRates != nil { + heartRates[i] = *p.section.heartRate + } + if cadences != nil { + cadences[i] = *p.section.cadence + } + if powers != nil { + powers[i] = *p.section.power + } + } + return ride.FromColumnsWithMetrics(distances, elevations, coords, timestamps, heartRates, cadences, powers) +} + +func buildPoints(sections []section, o options) []point { + lat, lon := 0.0, 0.0 + if o.startCoord != nil { + 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. + coord := func(distanceM float64) geodist.Coord { + return geodist.Coord{Lat: lat + headingPerKm*(distanceM/1000), Lon: lon + headingPerKm*(distanceM/1000)} + } + points := make([]point, 0, len(sections)+1) + elevation := o.startElevM + timestamp := o.startTime + points = append(points, point{ + distanceM: 0, + elevationM: elevation, + coord: coord(0), + timestamp: timestamp, + section: sections[0], + }) + distance := 0.0 + for _, sec := range sections { + curSecDist := 0.0 + for curSecDist < sec.distanceM { + curSecDist += o.samplingM + elevation += o.samplingM * sec.slope + timestamp = timestamp.Add(time.Second) + points = append(points, point{ + distanceM: distance + curSecDist, + elevationM: elevation, + coord: coord(distance + curSecDist), + timestamp: timestamp, + section: sec, + }) + } + distance += curSecDist + } + return points +} diff --git a/internal/ridetest/ride_test.go b/internal/ridetest/ride_test.go new file mode 100644 index 0000000..69c084d --- /dev/null +++ b/internal/ridetest/ride_test.go @@ -0,0 +1,146 @@ +package ridetest + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRideSingleSection(t *testing.T) { + r, err := Ride("1km at 10%") + require.NoError(t, err) + + assert.Len(t, r.Distances(), 11) + assert.Equal(t, 0.0, r.Distances()[0]) + assert.Equal(t, 1000.0, r.Distances()[10]) + assert.Equal(t, 0.0, r.Elevations()[0]) + assert.Equal(t, 100.0, r.Elevations()[10]) +} + +func TestRideSectionsWithDescentAndFlat(t *testing.T) { + r, err := Ride("0.5km at 10% | 0.5km at -10%") + require.NoError(t, err) + + assert.Len(t, r.Distances(), 11) + assert.Equal(t, 1000.0, r.Distances()[10]) + assert.Equal(t, 50.0, r.Elevations()[5]) // top of the first section + assert.Equal(t, 0.0, r.Elevations()[10]) // back to start elevation +} + +func TestRideSamplingOption(t *testing.T) { + r, err := Ride("1km at 10%", Sampling(50)) + require.NoError(t, err) + + assert.Len(t, r.Distances(), 21) + assert.Equal(t, 1000.0, r.Distances()[20]) + assert.Equal(t, 5.0, r.Elevations()[1]) +} + +func TestRideStartCoordHeading(t *testing.T) { + r, err := Ride("2km at 0%", StartCoord(43.10, 5.10), Sampling(1000)) + require.NoError(t, err) + + // Coordinates advance northeast by 0.01° per km; float64 literals like + // 43.11 drift in the last bits, so compare with a small tolerance. + require.Len(t, r.Coords(), 3) + assert.InDelta(t, 43.10, r.Coords()[0].Lat, 1e-9) + assert.InDelta(t, 5.10, r.Coords()[0].Lon, 1e-9) + assert.InDelta(t, 43.11, r.Coords()[1].Lat, 1e-9) + assert.InDelta(t, 5.11, r.Coords()[1].Lon, 1e-9) + assert.InDelta(t, 43.12, r.Coords()[2].Lat, 1e-9) + assert.InDelta(t, 5.12, r.Coords()[2].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)) + require.NoError(t, err) + + assert.Len(t, r.Timestamps(), 3) + assert.Equal(t, start, r.Timestamps()[0]) + assert.Equal(t, start.Add(time.Second), r.Timestamps()[1]) + assert.Equal(t, start.Add(2*time.Second), r.Timestamps()[2]) +} + +func TestRideStartElevation(t *testing.T) { + r, err := Ride("1km at 0%", StartElevation(100)) + require.NoError(t, err) + + assert.Equal(t, 100.0, r.Elevations()[0]) + assert.Equal(t, 100.0, r.Elevations()[10]) +} + +func TestRideMetricsCompleteColumns(t *testing.T) { + r, err := Ride("0.5km at 0% hr=150 cad=80 w=200 | 0.5km at 0% hr=140 cad=70 w=150") + require.NoError(t, err) + + heartRates, ok := r.HeartRates() + require.True(t, ok) + assert.Equal(t, 150.0, heartRates[0]) + assert.Equal(t, 150.0, heartRates[5]) + assert.Equal(t, 140.0, heartRates[6]) + assert.Equal(t, 140.0, heartRates[10]) + + cadences, ok := r.Cadences() + require.True(t, ok) + assert.Equal(t, 80.0, cadences[0]) + assert.Equal(t, 70.0, cadences[10]) + + powers, ok := r.Powers() + require.True(t, ok) + assert.Equal(t, 200.0, powers[0]) + assert.Equal(t, 150.0, powers[10]) +} + +func TestRideMetricsWithoutOptions(t *testing.T) { + r, err := Ride("1km at 0%") + require.NoError(t, err) + + _, ok := r.HeartRates() + assert.False(t, ok) + _, ok = r.Cadences() + assert.False(t, ok) + _, ok = r.Powers() + assert.False(t, ok) +} + +func TestRideMetricsPartialColumnFails(t *testing.T) { + _, err := Ride("0.5km at 0% hr=150 | 0.5km at 0%") + require.Error(t, err) + assert.Contains(t, err.Error(), "hr=") +} + +func TestRideParseErrors(t *testing.T) { + cases := []string{ + "", + " ", + "|", + "1km at 0% |", + "at 7%", + "1km 7%", + "0km at 7%", + "-1km at 7%", + "1km at abc%", + "xyz at 7%", + "1km away 7%", + "1km at 7% foo=1", + "1km at 7% hr=abc", + } + for _, dsl := range cases { + _, err := Ride(dsl) + require.Error(t, err, "dsl %q", dsl) + } +} + +func TestRideRejectsNonPositiveSampling(t *testing.T) { + _, err := Ride("1km at 0%", Sampling(0)) + require.Error(t, err) + _, err = Ride("1km at 0%", Sampling(-10)) + require.Error(t, err) +} + +func TestMustRidePanicsOnMalformedDSL(t *testing.T) { + assert.Panics(t, func() { MustRide("nope") }) +} 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, |
