diff options
Diffstat (limited to 'web/ride-filters_test.go')
| -rw-r--r-- | web/ride-filters_test.go | 256 |
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.") +} |