summaryrefslogtreecommitdiff
path: root/web/ride-filters_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'web/ride-filters_test.go')
-rw-r--r--web/ride-filters_test.go256
1 files changed, 256 insertions, 0 deletions
diff --git a/web/ride-filters_test.go b/web/ride-filters_test.go
new file mode 100644
index 0000000..c7b9133
--- /dev/null
+++ b/web/ride-filters_test.go
@@ -0,0 +1,256 @@
+package web
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/martinlehoux/biking_home/rides"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseRideFilters(t *testing.T) {
+ query := url.Values{
+ rideFilterDistanceMinParam: {"20"},
+ rideFilterDistanceMaxParam: {"100"},
+ rideFilterDurationMinParam: {"30"},
+ rideFilterDurationMaxParam: {"180"},
+ rideFilterDateFromParam: {"2026-08-01"},
+ rideFilterDateToParam: {"2026-08-31"},
+ rideFilterCotacolMinParam: {"50"},
+ rideFilterCotacolMaxParam: {"500"},
+ rideFilterRouteParam: {rideRouteFilterAvailable},
+ }
+
+ filters := parseRideFilters(query)
+
+ assert.Equal(t, 20, *filters.DistanceMinKm)
+ assert.Equal(t, 100, *filters.DistanceMaxKm)
+ assert.Equal(t, 30, *filters.DurationMinMinutes)
+ assert.Equal(t, 180, *filters.DurationMaxMinutes)
+ assert.Equal(t, "2026-08-01T00:00:00Z", filters.DateFrom.Format(time.RFC3339))
+ assert.Equal(t, "2026-09-01T00:00:00Z", filters.DateToExclusive.Format(time.RFC3339))
+ assert.Equal(t, 50, *filters.CotacolMinPoints)
+ assert.Equal(t, 500, *filters.CotacolMaxPoints)
+ assert.Equal(t, rideRouteFilterAvailable, filters.Route)
+ assert.True(t, filters.Active())
+}
+
+func TestParseRideFiltersIgnoresInvalidValues(t *testing.T) {
+ query := url.Values{
+ rideFilterDistanceMinParam: {"15"},
+ rideFilterDistanceMaxParam: {"305"},
+ rideFilterDurationMinParam: {"31"},
+ rideFilterDurationMaxParam: {"31"},
+ rideFilterDateFromParam: {"2026-09-01"},
+ rideFilterDateToParam: {"2026-08-01"},
+ rideFilterCotacolMinParam: {"25"},
+ rideFilterCotacolMaxParam: {"25"},
+ rideFilterRouteParam: {"unknown"},
+ }
+
+ filters := parseRideFilters(query)
+
+ assert.False(t, filters.Active())
+ assert.Nil(t, filters.DistanceMinKm)
+ assert.Nil(t, filters.DistanceMaxKm)
+ assert.Nil(t, filters.DurationMinMinutes)
+ assert.Nil(t, filters.DurationMaxMinutes)
+ assert.Nil(t, filters.DateFrom)
+ assert.Nil(t, filters.DateToExclusive)
+ assert.Nil(t, filters.CotacolMinPoints)
+ assert.Nil(t, filters.CotacolMaxPoints)
+ assert.Empty(t, filters.Route)
+}
+
+func TestParseRideFiltersTreatsFullRangesAsInactive(t *testing.T) {
+ filters := parseRideFilters(url.Values{
+ rideFilterDistanceMinParam: {"10"},
+ rideFilterDistanceMaxParam: {"300"},
+ rideFilterDurationMinParam: {"0"},
+ rideFilterDurationMaxParam: {"1440"},
+ rideFilterCotacolMinParam: {"0"},
+ rideFilterCotacolMaxParam: {"5000"},
+ })
+
+ assert.False(t, filters.Active())
+ assert.Nil(t, filters.DistanceMinKm)
+ assert.Nil(t, filters.DistanceMaxKm)
+ assert.Nil(t, filters.DurationMinMinutes)
+ assert.Nil(t, filters.DurationMaxMinutes)
+ assert.Nil(t, filters.CotacolMinPoints)
+ assert.Nil(t, filters.CotacolMaxPoints)
+}
+
+func TestFilterRidesUsesInclusiveBoundsAndMovingTime(t *testing.T) {
+ items := []rides.Ride{
+ {Name: "lower boundary", DistanceM: 20_000, MovingTimeS: 1_800, StartDate: time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC), GPXPath: "lower.gpx"},
+ {Name: "upper boundary", DistanceM: 100_000, MovingTimeS: 10_800, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "upper.gpx"},
+ {Name: "outside distance", DistanceM: 110_000, MovingTimeS: 10_800, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "outside-distance.gpx"},
+ {Name: "outside moving time", DistanceM: 100_000, MovingTimeS: 10_801, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "outside-duration.gpx"},
+ {Name: "outside date", DistanceM: 100_000, MovingTimeS: 10_800, StartDate: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC), GPXPath: "outside-date.gpx"},
+ }
+ filters := parseRideFilters(url.Values{
+ rideFilterDistanceMinParam: {"20"},
+ rideFilterDistanceMaxParam: {"100"},
+ rideFilterDurationMinParam: {"30"},
+ rideFilterDurationMaxParam: {"180"},
+ rideFilterDateFromParam: {"2026-08-01"},
+ rideFilterDateToParam: {"2026-08-31"},
+ })
+
+ filtered := filterRides(items, filters)
+
+ require.Len(t, filtered, 2)
+ assert.Equal(t, "lower boundary", filtered[0].Name)
+ assert.Equal(t, "upper boundary", filtered[1].Name)
+}
+
+func TestFilterRidesByRouteAvailability(t *testing.T) {
+ items := []rides.Ride{
+ {Name: "recorded", GPXPath: "recorded.gpx"},
+ {Name: "metadata only"},
+ }
+
+ available := filterRides(items, parseRideFilters(url.Values{rideFilterRouteParam: {rideRouteFilterAvailable}}))
+ missing := filterRides(items, parseRideFilters(url.Values{rideFilterRouteParam: {rideRouteFilterMissing}}))
+
+ require.Len(t, available, 1)
+ assert.Equal(t, "recorded", available[0].Name)
+ require.Len(t, missing, 1)
+ assert.Equal(t, "metadata only", missing[0].Name)
+}
+
+func TestFilterRidesByCotacolExcludesUnavailableScores(t *testing.T) {
+ _, db := newWebTestServer(t)
+ current := rides.Ride{
+ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000",
+ GPXPath: testGPXPath(t, "current-cotacol.gpx"),
+ Name: "Current score",
+ Type: "Ride",
+ StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
+ DistanceM: 20_000,
+ }
+ stale := rides.Ride{
+ ExternalID: "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8",
+ GPXPath: testGPXPath(t, "stale-cotacol.gpx"),
+ Name: "Stale score",
+ Type: "Ride",
+ StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC),
+ DistanceM: 20_000,
+ }
+ unavailable := rides.Ride{
+ ExternalID: "strava:7ba7b810-9dad-41d1-80b4-00c04fd430c8",
+ Name: "No score",
+ Type: "VirtualRide",
+ StartDate: time.Date(2026, 8, 3, 7, 0, 0, 0, time.UTC),
+ DistanceM: 20_000,
+ }
+ require.NoError(t, rides.Save(db, current))
+ require.NoError(t, rides.Save(db, stale))
+ require.NoError(t, rides.Save(db, unavailable))
+ _, err := db.Exec(`
+ UPDATE rides
+ SET cotacol_score = CASE external_id
+ WHEN ? THEN 250
+ WHEN ? THEN 250
+ END,
+ cotacol_algo_version = CASE external_id
+ WHEN ? THEN ?
+ WHEN ? THEN 'old'
+ END
+ WHERE external_id IN (?, ?)
+ `, current.ExternalID, stale.ExternalID, current.ExternalID, ride.CotacolAlgorithmVersion, stale.ExternalID, current.ExternalID, stale.ExternalID)
+ require.NoError(t, err)
+ items, err := rides.List(db)
+ require.NoError(t, err)
+ filters := parseRideFilters(url.Values{rideFilterCotacolMinParam: {"200"}, rideFilterCotacolMaxParam: {"300"}})
+
+ filtered := filterRides(items, filters)
+
+ require.Len(t, filtered, 1)
+ assert.Equal(t, current.ExternalID, filtered[0].ExternalID)
+}
+
+func TestHandlerRendersRideFilterControls(t *testing.T) {
+ server, _ := newWebTestServer(t)
+ request := httptest.NewRequest(http.MethodGet, "/", nil)
+ response := httptest.NewRecorder()
+
+ server.Handler().ServeHTTP(response, request)
+
+ body := response.Body.String()
+ assert.Equal(t, http.StatusOK, response.Code)
+ assert.Contains(t, body, `id="ride-filters"`)
+ assert.Contains(t, body, `name="distance_min"`)
+ assert.Contains(t, body, `name="duration_min"`)
+ assert.Contains(t, body, `name="date_from"`)
+ assert.Contains(t, body, `name="cotacol_min"`)
+ assert.Contains(t, body, `name="route"`)
+ assert.Contains(t, body, `<script type="module" src="/static/ride-filters.js"></script>`)
+}
+
+func TestRideSortHeadersPreserveFilters(t *testing.T) {
+ filters := parseRideFilters(url.Values{
+ rideFilterDistanceMinParam: {"50"},
+ rideFilterDistanceMaxParam: {"150"},
+ rideFilterRouteParam: {rideRouteFilterAvailable},
+ })
+
+ headers := rideSortHeadersWithFilters(RideSort{Column: rideSortDistance, Descending: true}, filters)
+
+ assert.Equal(t, "/?dir=asc&distance_max=150&distance_min=50&route=available&sort=name", headers[0].URL)
+}
+
+func TestHandlerFiltersRidesBeforeRenderingSortedResults(t *testing.T) {
+ server, db := newWebTestServer(t)
+ save := func(externalID, name, path string, distance float64) {
+ require.NoError(t, rides.Save(db, rides.Ride{
+ ExternalID: externalID,
+ GPXPath: testGPXPath(t, path),
+ Name: name,
+ Type: "Ride",
+ StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
+ DistanceM: distance,
+ }))
+ }
+ save("strava:550e8400-e29b-41d4-a716-446655440000", "Long Ride", "long-filtered.gpx", 40_000)
+ save("strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", "Short Ride", "short-filtered.gpx", 20_000)
+ save("strava:7ba7b810-9dad-41d1-80b4-00c04fd430c8", "Too Far Ride", "far-filtered.gpx", 320_000)
+
+ request := httptest.NewRequest(http.MethodGet, "/?distance_min=10&distance_max=100&sort=distance&dir=asc", nil)
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+
+ body := response.Body.String()
+ assert.Equal(t, http.StatusOK, response.Code)
+ assert.Less(t, strings.Index(body, "Short Ride"), strings.Index(body, "Long Ride"))
+ assert.NotContains(t, body, "Too Far Ride")
+}
+
+func TestHandlerRendersFilteredEmptyState(t *testing.T) {
+ server, db := newWebTestServer(t)
+ require.NoError(t, rides.Save(db, rides.Ride{
+ ExternalID: "strava:8ba7b810-9dad-41d1-80b4-00c04fd430c8",
+ GPXPath: testGPXPath(t, "empty-filter.gpx"),
+ Name: "Short Ride",
+ Type: "Ride",
+ StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
+ DistanceM: 20_000,
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/?distance_min=300&distance_max=300", nil)
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+
+ body := response.Body.String()
+ assert.Equal(t, http.StatusOK, response.Code)
+ assert.Contains(t, body, "No rides match the selected filters.")
+ assert.Contains(t, body, `href="/">Clear filters.</a>`)
+ assert.NotContains(t, body, "No rides stored yet.")
+}