go-places/api/internal/geo/geo_test.go
2026-10-07 15:14:50 +02:00

219 lines
6.6 KiB
Go

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}
}