package geo import ( "math" "testing" "go-places-api/internal/domain" ) // TestDistanceKnownSeparations checks the haversine result against separations // whose expected value is well established. func TestDistanceKnownSeparations(t *testing.T) { t.Parallel() cases := []struct { name string a, b domain.Coordinate wantMetres float64 tolerance float64 }{ { // One degree of latitude is about 111.2 km anywhere. name: "one degree of latitude", a: domain.Coordinate{Latitude: 0, Longitude: 0}, b: domain.Coordinate{Latitude: 1, Longitude: 0}, wantMetres: 111195, tolerance: 100, }, { // A degree of longitude is the same at the equator. name: "one degree of longitude at the equator", a: domain.Coordinate{Latitude: 0, Longitude: 0}, b: domain.Coordinate{Latitude: 0, Longitude: 1}, wantMetres: 111195, tolerance: 100, }, { // Paris to London, roughly 344 km. name: "Paris to London", a: domain.Coordinate{Latitude: 48.8566, Longitude: 2.3522}, b: domain.Coordinate{Latitude: 51.5074, Longitude: -0.1278}, wantMetres: 343500, tolerance: 3000, }, { // A short walk: the same spot is zero distance away. name: "identical points", a: domain.Coordinate{Latitude: 45.0, Longitude: 9.0}, b: domain.Coordinate{Latitude: 45.0, Longitude: 9.0}, wantMetres: 0, tolerance: 0.001, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() got := DistanceMeters(tc.a, tc.b) if math.Abs(got-tc.wantMetres) > tc.tolerance { t.Errorf("DistanceMeters = %.1f m, want %.1f m (±%.1f)", got, tc.wantMetres, tc.tolerance) } }) } } // TestDistanceIsSymmetric guards against an asymmetry bug in the formula. func TestDistanceIsSymmetric(t *testing.T) { t.Parallel() a := domain.Coordinate{Latitude: 52.5200, Longitude: 13.4050} b := domain.Coordinate{Latitude: 48.8566, Longitude: 2.3522} forward := DistanceMeters(a, b) backward := DistanceMeters(b, a) if math.Abs(forward-backward) > 1e-6 { t.Errorf("distance is not symmetric: %.6f vs %.6f", forward, backward) } } // TestLongitudeDegreeShrinksTowardsPoles confirms the latitude dependency that // the bounding box calculation relies on. func TestLongitudeDegreeShrinksTowardsPoles(t *testing.T) { t.Parallel() atEquator := DistanceMeters( domain.Coordinate{Latitude: 0, Longitude: 0}, domain.Coordinate{Latitude: 0, Longitude: 1}, ) atSixty := DistanceMeters( domain.Coordinate{Latitude: 60, Longitude: 0}, domain.Coordinate{Latitude: 60, Longitude: 1}, ) // A degree of longitude at 60 degrees is half the equatorial distance. if ratio := atSixty / atEquator; math.Abs(ratio-0.5) > 0.01 { t.Errorf("longitude degree at 60 lat = %.3f of equatorial, want 0.500", ratio) } } func TestWithinRadius(t *testing.T) { t.Parallel() // Two points about 22 m apart. origin := domain.Coordinate{Latitude: 48.8584, Longitude: 2.2945} // 0.0002 degrees of latitude is roughly 22 m. nearby := domain.Coordinate{Latitude: 48.8586, Longitude: 2.2945} far := domain.Coordinate{Latitude: 48.8684, Longitude: 2.2945} if !WithinRadius(origin, nearby, 50) { t.Errorf("expected points ~22 m apart to be within 50 m, got %.1f m", DistanceMeters(origin, nearby)) } if WithinRadius(origin, far, 50) { t.Errorf("expected points ~1112 m apart to be outside 50 m") } // The boundary itself is inclusive. if !WithinRadius(origin, nearby, DistanceMeters(origin, nearby)) { t.Errorf("expected the radius boundary to be inclusive") } } // TestBoundingBoxContainsCircle is the property the SQL pre-filter relies on: // every point within the radius must fall inside the box, so the box is only // ever a superset and never loses a candidate. func TestBoundingBoxContainsCircle(t *testing.T) { t.Parallel() centres := []domain.Coordinate{ {Latitude: 0, Longitude: 0}, {Latitude: 48.8566, Longitude: 2.3522}, {Latitude: -33.8688, Longitude: 151.2093}, {Latitude: 64.1466, Longitude: -21.9426}, } radii := []float64{50, 150, 5000} for _, c := range centres { for _, radius := range radii { box := Around(c, radius) for _, bearing := range []float64{0, 45, 90, 135, 180, 225, 270, 315} { p := offset(c, bearing, radius) if !box.Contains(p) { t.Errorf("box around %+v r=%v excludes point at bearing %v (%+v): %+v", c, radius, bearing, p, box) } } } } } // TestBoundingBoxHandlesPoles ensures a circle at the pole does not divide by // the vanishing cosine of the latitude. func TestBoundingBoxHandlesPoles(t *testing.T) { t.Parallel() box := Around(domain.Coordinate{Latitude: 90, Longitude: 0}, 1000) if box.MinLon != -180 || box.MaxLon != 180 { t.Errorf("at the pole the box should span all longitudes, got lon %v..%v", box.MinLon, box.MaxLon) } if box.MaxLat > 90 || box.MinLat < -90 { t.Errorf("latitude bounds must stay in range, got %v..%v", box.MinLat, box.MaxLat) } } func TestRound(t *testing.T) { t.Parallel() cases := []struct { in, want float64 }{ {0, 0}, {0.4, 0}, {0.5, 1}, {1.4, 1}, {1.5, 2}, {123.456, 123}, {-0.6, -1}, } for _, tc := range cases { if got := Round(tc.in); got != tc.want { t.Errorf("Round(%v) = %v, want %v", tc.in, got, tc.want) } } } func TestCoordinateValid(t *testing.T) { t.Parallel() cases := []struct { coord domain.Coordinate want bool }{ {domain.Coordinate{Latitude: 0, Longitude: 0}, true}, {domain.Coordinate{Latitude: 48.85, Longitude: 2.35}, true}, {domain.Coordinate{Latitude: 90, Longitude: 180}, true}, {domain.Coordinate{Latitude: -90, Longitude: -180}, true}, {domain.Coordinate{Latitude: 90.1, Longitude: 0}, false}, {domain.Coordinate{Latitude: -90.1, Longitude: 0}, false}, {domain.Coordinate{Latitude: 0, Longitude: 180.1}, false}, {domain.Coordinate{Latitude: 0, Longitude: -180.1}, false}, } for _, tc := range cases { if got := tc.coord.Valid(); got != tc.want { t.Errorf("%+v.Valid() = %v, want %v", tc.coord, got, tc.want) } } } // offset moves a point by bearing degrees and distance metres using a simple // equirectangular approximation, which is accurate enough over the small // distances these tests use. func offset(origin domain.Coordinate, bearingDeg, metres float64) domain.Coordinate { const degToRad = math.Pi / 180 lat := origin.Latitude + (metres*math.Cos(bearingDeg*degToRad))/EarthRadiusMeters*180/math.Pi lon := origin.Longitude + (metres*math.Sin(bearingDeg*degToRad))/ (EarthRadiusMeters*math.Cos(origin.Latitude*degToRad))*180/math.Pi return domain.Coordinate{Latitude: lat, Longitude: lon} }