From 8a0032d08862dcf28c653bee3fd21052e967e35d Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 18 Oct 2026 21:19:36 +0200 Subject: Cut climb profile sections in the backend The chart steps are computed in Go and shipped as finished sections, next to the profile statistics the climb package already owns: climb.ProfileSections cuts a Route at its widest bends under a ProfilePolicy, and rideanalysis.MergeClimbProfile takes over the merge that lived in web/climbs.go, so web only loads the attempt rides and maps the DTOs. The payload carries sections instead of raw elevations, and the browser keeps what needs the canvas: slope bands, labels, distance ticks and colours. official-climb-profile-logic.ts and its tests are gone, replaced by climb/profile_test.go and a page test reading the embedded profile JSON. --- climb/profile_test.go | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 climb/profile_test.go (limited to 'climb/profile_test.go') diff --git a/climb/profile_test.go b/climb/profile_test.go new file mode 100644 index 0000000..1342916 --- /dev/null +++ b/climb/profile_test.go @@ -0,0 +1,113 @@ +package climb + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stretch describes one [lengthM, slopePercent] stretch of the buildProfile trace. +type stretch struct { + lengthM float64 + slopePercent float64 +} + +// buildProfile lays out one recorded point every 50 m along the given stretches. +func buildProfile(stretches ...stretch) Route { + route := Route{Distances: []float64{0}, Elevations: []float64{0}} + distanceM, elevationM := 0.0, 0.0 + for _, stretch := range stretches { + pointCount := int(math.Round(stretch.lengthM / 50)) + for index := 1; index <= pointCount; index++ { + stepM := stretch.lengthM * float64(index) / float64(pointCount) + route.Distances = append(route.Distances, distanceM+stepM) + route.Elevations = append(route.Elevations, elevationM+stepM*stretch.slopePercent/100) + } + distanceM += stretch.lengthM + elevationM += stretch.lengthM * stretch.slopePercent / 100 + } + return route +} + +func TestProfileSectionsKeepsShortRamp(t *testing.T) { + route := buildProfile(stretch{300, 4}, stretch{300, 12}, stretch{300, 4}) + + sections := route.ProfileSections(DefaultProfilePolicy()) + + require.Len(t, sections, 3) + assert.InDelta(t, 4, sections[0].SlopePercent, 0.01) + assert.InDelta(t, 12, sections[1].SlopePercent, 0.01) + assert.InDelta(t, 4, sections[2].SlopePercent, 0.01) + assert.InDelta(t, 300, sections[1].EndDistanceM-sections[1].StartDistanceM, 0.01) +} + +func TestProfileSectionsCollapsesConstantSlope(t *testing.T) { + route := buildProfile(stretch{5000, 6}) + + sections := route.ProfileSections(DefaultProfilePolicy()) + + require.Len(t, sections, 1) + assert.InDelta(t, 6, sections[0].SlopePercent, 0.01) + assert.Zero(t, sections[0].StartDistanceM) + assert.InDelta(t, 5000, sections[0].EndDistanceM, 0.01) +} + +func TestProfileSectionsReportsDescent(t *testing.T) { + route := buildProfile(stretch{2000, -5}) + + sections := route.ProfileSections(DefaultProfilePolicy()) + + require.Len(t, sections, 1) + assert.InDelta(t, -5, sections[0].SlopePercent, 0.01) +} + +func TestProfileSectionsHonoursBudget(t *testing.T) { + route := Route{} + for index := 0; index <= 200; index++ { + distanceM := float64(index) * 50 + route.Distances = append(route.Distances, distanceM) + route.Elevations = append(route.Elevations, distanceM*0.05+math.Sin(float64(index))*4) + } + + sections := route.ProfileSections(DefaultProfilePolicy()) + + assert.Greater(t, len(sections), 10) + assert.LessOrEqual(t, len(sections), DefaultProfilePolicy().MaxSections) + + budgeted := route.ProfileSections(ProfilePolicy{MaxSections: 5, MinBendM: 1, BendPerBaseStep: 0.01}) + assert.LessOrEqual(t, len(budgeted), 5) +} + +func TestProfileSectionsKeepsAbsoluteDistances(t *testing.T) { + route := buildProfile(stretch{2000, 8}) + for index := range route.Distances { + route.Distances[index] += 5000 + } + + sections := route.ProfileSections(DefaultProfilePolicy()) + + require.Len(t, sections, 1) + assert.InDelta(t, 5000, sections[0].StartDistanceM, 0.01) + assert.InDelta(t, 7000, sections[0].EndDistanceM, 0.01) +} + +func TestProfileSectionsReadsRouteSlice(t *testing.T) { + route := buildProfile(stretch{1000, 20}, stretch{1000, 8}, stretch{1000, 20}) + start := 20 + end := 40 + + sections := route.Slice(start, end).ProfileSections(DefaultProfilePolicy()) + + require.Len(t, sections, 1) + assert.InDelta(t, 8, sections[0].SlopePercent, 0.01) + assert.InDelta(t, route.Distances[start], sections[0].StartDistanceM, 0.01) + assert.InDelta(t, route.Distances[end], sections[0].EndDistanceM, 0.01) +} + +func TestProfileSectionsRejectsShortRoute(t *testing.T) { + assert.Nil(t, Route{}.ProfileSections(DefaultProfilePolicy())) + assert.Nil(t, Route{Distances: []float64{0}, Elevations: []float64{0}}.ProfileSections(DefaultProfilePolicy())) + assert.Nil(t, Route{Distances: []float64{0, 10}, Elevations: []float64{0}}.ProfileSections(DefaultProfilePolicy())) +} -- cgit v1.3