package official_climb_test import ( "testing" "time" "github.com/jftuga/geodist" "github.com/martinlehoux/biking_home/internal/dbtest" "github.com/martinlehoux/biking_home/official_climb" "github.com/martinlehoux/biking_home/ride" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCreateListAndGetOfficialClimb(t *testing.T) { db := dbtest.New(t) created, err := official_climb.Create(db, official_climb.OfficialClimb{ Name: "Col de Test", StartCoord: geodist.Coord{Lat: 43.1, Lon: 5.1}, EndCoord: geodist.Coord{Lat: 43.2, Lon: 5.2}, }) require.NoError(t, err) assert.Positive(t, created.ID) assert.Equal(t, "Col de Test", created.Name) assert.Equal(t, geodist.Coord{Lat: 43.1, Lon: 5.1}, created.StartCoord) listed, err := official_climb.List(db) require.NoError(t, err) require.Len(t, listed, 1) assert.Equal(t, created.ID, listed[0].ID) got, found, err := official_climb.GetByID(db, created.ID) require.NoError(t, err) require.True(t, found) assert.Equal(t, created.EndCoord, got.EndCoord) assert.False(t, got.CreatedAt.IsZero()) assert.False(t, got.UpdatedAt.IsZero()) } func TestCreateRejectsInvalidOfficialClimb(t *testing.T) { db := dbtest.New(t) _, err := official_climb.Create(db, official_climb.OfficialClimb{ Name: " ", StartCoord: geodist.Coord{Lat: 43.1, Lon: 5.1}, EndCoord: geodist.Coord{Lat: 43.2, Lon: 5.2}, }) assert.EqualError(t, err, "official climb name is required") } func TestMatchPolicyRejectsInvalidRadius(t *testing.T) { assert.EqualError(t, (official_climb.MatchPolicy{}).Validate(), "official climb endpoint radius must be greater than zero") } func TestMatchClimbMatchesWithoutDetectedClimb(t *testing.T) { parsed := ride.FromColumns( []float64{0, 1000, 2000, 3000}, []float64{100, 150, 200, 100}, []geodist.Coord{ {Lat: 43.10, Lon: 5.10}, {Lat: 43.11, Lon: 5.11}, {Lat: 43.12, Lon: 5.12}, {Lat: 43.13, Lon: 5.13}, }, []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0)}, ) official := official_climb.OfficialClimb{ Name: "On route", StartCoord: parsed.Coord(1), EndCoord: parsed.Coord(2), } matched, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 1, matched.StartIndex) assert.Equal(t, 2, matched.EndIndex) } func TestMatchClimbMatchesEndpointInsideRadius(t *testing.T) { parsed := ride.FromColumns( []float64{0, 1000, 2000, 3000}, []float64{100, 150, 200, 100}, []geodist.Coord{ {Lat: 43.10, Lon: 5.10}, {Lat: 43.11, Lon: 5.11}, {Lat: 43.12, Lon: 5.12}, {Lat: 43.13, Lon: 5.13}, }, []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0)}, ) official := official_climb.OfficialClimb{ Name: "Offset from route", StartCoord: geodist.Coord{Lat: 43.1101, Lon: 5.1101}, EndCoord: geodist.Coord{Lat: 43.1201, Lon: 5.1201}, } matched, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 1, matched.StartIndex) assert.Equal(t, 2, matched.EndIndex) } func TestMatchClimbRejectsReversedEndpoints(t *testing.T) { parsed := ride.FromColumns( []float64{0, 1000, 2000, 3000}, []float64{100, 150, 200, 100}, []geodist.Coord{ {Lat: 43.10, Lon: 5.10}, {Lat: 43.11, Lon: 5.11}, {Lat: 43.12, Lon: 5.12}, {Lat: 43.13, Lon: 5.13}, }, []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0)}, ) official := official_climb.OfficialClimb{ Name: "Reversed", StartCoord: parsed.Coord(2), EndCoord: parsed.Coord(1), } _, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) assert.False(t, found) } func TestMatchClimbRejectsEndpointOutsideRadius(t *testing.T) { parsed := ride.FromColumns( []float64{0, 1000, 2000, 3000}, []float64{100, 150, 200, 100}, []geodist.Coord{ {Lat: 43.10, Lon: 5.10}, {Lat: 43.11, Lon: 5.11}, {Lat: 43.12, Lon: 5.12}, {Lat: 43.13, Lon: 5.13}, }, []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0)}, ) official := official_climb.OfficialClimb{ Name: "Too far", StartCoord: geodist.Coord{Lat: 43.09, Lon: 5.09}, EndCoord: parsed.Coord(2), } _, found := official_climb.MatchClimb(parsed.Coords, official, 10) assert.False(t, found) } func TestMatchClimbsMatchesTwoLaps(t *testing.T) { coords := []geodist.Coord{ {Lat: 43.10, Lon: 5.10}, {Lat: 43.11, Lon: 5.11}, {Lat: 43.12, Lon: 5.12}, {Lat: 43.13, Lon: 5.13}, {Lat: 43.10, Lon: 5.10}, {Lat: 43.11, Lon: 5.11}, {Lat: 43.12, Lon: 5.12}, {Lat: 43.13, Lon: 5.13}, } parsed := ride.FromColumns( []float64{0, 1000, 2000, 3000, 4000, 5000, 6000, 7000}, []float64{100, 150, 200, 100, 100, 150, 200, 100}, coords, []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0), time.Unix(240, 0), time.Unix(300, 0), time.Unix(360, 0), time.Unix(420, 0)}, ) official := official_climb.OfficialClimb{ Name: "Loop", StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, } matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 2) assert.Equal(t, 0, matches[0].StartIndex) assert.Equal(t, 2, matches[0].EndIndex) assert.Equal(t, 4, matches[1].StartIndex) assert.Equal(t, 6, matches[1].EndIndex) // Single-match helper still returns first occurrence for backward compatibility. single, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 0, single.StartIndex) } func TestMatchClimbsMatchesOnlyCompleteLaps(t *testing.T) { coords := []geodist.Coord{ {Lat: 43.10, Lon: 5.10}, {Lat: 43.11, Lon: 5.11}, {Lat: 43.13, Lon: 5.13}, {Lat: 43.10, Lon: 5.10}, {Lat: 43.11, Lon: 5.11}, {Lat: 43.12, Lon: 5.12}, } parsed := ride.FromColumns( []float64{0, 1000, 2000, 3000, 4000, 5000}, []float64{100, 150, 100, 100, 150, 200}, coords, []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0), time.Unix(240, 0), time.Unix(300, 0)}, ) official := official_climb.OfficialClimb{ Name: "Partial", StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, } matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 1) assert.Equal(t, 3, matches[0].StartIndex) assert.Equal(t, 5, matches[0].EndIndex) } func TestMatchClimbsPreservesDirectionAcrossLaps(t *testing.T) { coords := []geodist.Coord{ {Lat: 43.12, Lon: 5.12}, {Lat: 43.11, Lon: 5.11}, {Lat: 43.10, Lon: 5.10}, } parsed := ride.FromColumns( []float64{0, 1000, 2000}, []float64{200, 150, 100}, coords, []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0)}, ) official := official_climb.OfficialClimb{ Name: "Directional", StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, } matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) assert.Empty(t, matches) } func TestMatchClimbsClustersContiguousPoints(t *testing.T) { coords := []geodist.Coord{ {Lat: 43.1000, Lon: 5.1000}, {Lat: 43.1001, Lon: 5.1001}, {Lat: 43.1001, Lon: 5.1001}, {Lat: 43.11, Lon: 5.11}, {Lat: 43.12, Lon: 5.12}, {Lat: 43.1000, Lon: 5.1000}, {Lat: 43.1001, Lon: 5.1001}, {Lat: 43.12, Lon: 5.12}, } parsed := ride.FromColumns( []float64{0, 100, 200, 1000, 2000, 3000, 3100, 4000}, []float64{100, 101, 102, 150, 200, 100, 101, 200}, coords, []time.Time{time.Unix(0, 0), time.Unix(10, 0), time.Unix(20, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0), time.Unix(190, 0), time.Unix(240, 0)}, ) official := official_climb.OfficialClimb{ Name: "Clustered", StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, } matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 2) assert.Equal(t, 0, matches[0].StartIndex) assert.Equal(t, 4, matches[0].EndIndex) assert.Equal(t, 5, matches[1].StartIndex) assert.Equal(t, 7, matches[1].EndIndex) }