mirror of https://github.com/tailscale/tailscale/
types/geo: add geo.Point and its associated units (#16583)
Package geo provides functionality to represent and process geographical locations on a sphere. The main type, geo.Point, represents a pair of latitude and longitude coordinates. Updates tailscale/corp#29968 Signed-off-by: Simon Law <sfllaw@tailscale.com>pull/16590/head
parent
e7238efafa
commit
93511be044
@ -0,0 +1,6 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package geo provides functionality to represent and process geographical
|
||||
// locations on a spherical Earth.
|
||||
package geo
|
||||
@ -0,0 +1,541 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package geo_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"testing/quick"
|
||||
|
||||
"tailscale.com/types/geo"
|
||||
)
|
||||
|
||||
func TestPointZero(t *testing.T) {
|
||||
var zero geo.Point
|
||||
|
||||
if got := zero.IsZero(); !got {
|
||||
t.Errorf("IsZero() got %t", got)
|
||||
}
|
||||
|
||||
if got := zero.Valid(); got {
|
||||
t.Errorf("Valid() got %t", got)
|
||||
}
|
||||
|
||||
wantErr := geo.ErrBadPoint.Error()
|
||||
if _, _, err := zero.LatLng(); err.Error() != wantErr {
|
||||
t.Errorf("LatLng() err %q, want %q", err, wantErr)
|
||||
}
|
||||
|
||||
wantStr := "nowhere"
|
||||
if got := zero.String(); got != wantStr {
|
||||
t.Errorf("String() got %q, want %q", got, wantStr)
|
||||
}
|
||||
|
||||
wantB := []byte{0, 0, 0, 0, 0, 0, 0, 0}
|
||||
if b, err := zero.MarshalBinary(); err != nil {
|
||||
t.Errorf("MarshalBinary() err %q, want nil", err)
|
||||
} else if string(b) != string(wantB) {
|
||||
t.Errorf("MarshalBinary got %q, want %q", b, wantB)
|
||||
}
|
||||
|
||||
wantI := uint64(0x00000000)
|
||||
if i, err := zero.MarshalUint64(); err != nil {
|
||||
t.Errorf("MarshalUint64() err %q, want nil", err)
|
||||
} else if i != wantI {
|
||||
t.Errorf("MarshalUint64 got %v, want %v", i, wantI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoint(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
lat geo.Degrees
|
||||
lng geo.Degrees
|
||||
wantLat geo.Degrees
|
||||
wantLng geo.Degrees
|
||||
wantString string
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "null-island",
|
||||
lat: +0.0,
|
||||
lng: +0.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +0.0,
|
||||
wantString: "+0° +0°",
|
||||
wantText: "POINT (0 0)",
|
||||
},
|
||||
{
|
||||
name: "north-pole",
|
||||
lat: +90.0,
|
||||
lng: +0.0,
|
||||
wantLat: +90.0,
|
||||
wantLng: +0.0,
|
||||
wantString: "+90° +0°",
|
||||
wantText: "POINT (0 90)",
|
||||
},
|
||||
{
|
||||
name: "south-pole",
|
||||
lat: -90.0,
|
||||
lng: +0.0,
|
||||
wantLat: -90.0,
|
||||
wantLng: +0.0,
|
||||
wantString: "-90° +0°",
|
||||
wantText: "POINT (0 -90)",
|
||||
},
|
||||
{
|
||||
name: "north-pole-weird-longitude",
|
||||
lat: +90.0,
|
||||
lng: +1.0,
|
||||
wantLat: +90.0,
|
||||
wantLng: +0.0,
|
||||
wantString: "+90° +0°",
|
||||
wantText: "POINT (0 90)",
|
||||
},
|
||||
{
|
||||
name: "south-pole-weird-longitude",
|
||||
lat: -90.0,
|
||||
lng: +1.0,
|
||||
wantLat: -90.0,
|
||||
wantLng: +0.0,
|
||||
wantString: "-90° +0°",
|
||||
wantText: "POINT (0 -90)",
|
||||
},
|
||||
{
|
||||
name: "almost-north",
|
||||
lat: +89.0,
|
||||
lng: +0.0,
|
||||
wantLat: +89.0,
|
||||
wantLng: +0.0,
|
||||
wantString: "+89° +0°",
|
||||
wantText: "POINT (0 89)",
|
||||
},
|
||||
{
|
||||
name: "past-north",
|
||||
lat: +91.0,
|
||||
lng: +0.0,
|
||||
wantLat: +89.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "+89° +180°",
|
||||
wantText: "POINT (180 89)",
|
||||
},
|
||||
{
|
||||
name: "almost-south",
|
||||
lat: -89.0,
|
||||
lng: +0.0,
|
||||
wantLat: -89.0,
|
||||
wantLng: +0.0,
|
||||
wantString: "-89° +0°",
|
||||
wantText: "POINT (0 -89)",
|
||||
},
|
||||
{
|
||||
name: "past-south",
|
||||
lat: -91.0,
|
||||
lng: +0.0,
|
||||
wantLat: -89.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "-89° +180°",
|
||||
wantText: "POINT (180 -89)",
|
||||
},
|
||||
{
|
||||
name: "antimeridian-north",
|
||||
lat: +180.0,
|
||||
lng: +0.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "+0° +180°",
|
||||
wantText: "POINT (180 0)",
|
||||
},
|
||||
{
|
||||
name: "antimeridian-south",
|
||||
lat: -180.0,
|
||||
lng: +0.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "+0° +180°",
|
||||
wantText: "POINT (180 0)",
|
||||
},
|
||||
{
|
||||
name: "almost-antimeridian-north",
|
||||
lat: +179.0,
|
||||
lng: +0.0,
|
||||
wantLat: +1.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "+1° +180°",
|
||||
wantText: "POINT (180 1)",
|
||||
},
|
||||
{
|
||||
name: "past-antimeridian-north",
|
||||
lat: +181.0,
|
||||
lng: +0.0,
|
||||
wantLat: -1.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "-1° +180°",
|
||||
wantText: "POINT (180 -1)",
|
||||
},
|
||||
{
|
||||
name: "almost-antimeridian-south",
|
||||
lat: -179.0,
|
||||
lng: +0.0,
|
||||
wantLat: -1.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "-1° +180°",
|
||||
wantText: "POINT (180 -1)",
|
||||
},
|
||||
{
|
||||
name: "past-antimeridian-south",
|
||||
lat: -181.0,
|
||||
lng: +0.0,
|
||||
wantLat: +1.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "+1° +180°",
|
||||
wantText: "POINT (180 1)",
|
||||
},
|
||||
{
|
||||
name: "circumnavigate-north",
|
||||
lat: +360.0,
|
||||
lng: +1.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +1.0,
|
||||
wantString: "+0° +1°",
|
||||
wantText: "POINT (1 0)",
|
||||
},
|
||||
{
|
||||
name: "circumnavigate-south",
|
||||
lat: -360.0,
|
||||
lng: +1.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +1.0,
|
||||
wantString: "+0° +1°",
|
||||
wantText: "POINT (1 0)",
|
||||
},
|
||||
{
|
||||
name: "almost-circumnavigate-north",
|
||||
lat: +359.0,
|
||||
lng: +1.0,
|
||||
wantLat: -1.0,
|
||||
wantLng: +1.0,
|
||||
wantString: "-1° +1°",
|
||||
wantText: "POINT (1 -1)",
|
||||
},
|
||||
{
|
||||
name: "past-circumnavigate-north",
|
||||
lat: +361.0,
|
||||
lng: +1.0,
|
||||
wantLat: +1.0,
|
||||
wantLng: +1.0,
|
||||
wantString: "+1° +1°",
|
||||
wantText: "POINT (1 1)",
|
||||
},
|
||||
{
|
||||
name: "almost-circumnavigate-south",
|
||||
lat: -359.0,
|
||||
lng: +1.0,
|
||||
wantLat: +1.0,
|
||||
wantLng: +1.0,
|
||||
wantString: "+1° +1°",
|
||||
wantText: "POINT (1 1)",
|
||||
},
|
||||
{
|
||||
name: "past-circumnavigate-south",
|
||||
lat: -361.0,
|
||||
lng: +1.0,
|
||||
wantLat: -1.0,
|
||||
wantLng: +1.0,
|
||||
wantString: "-1° +1°",
|
||||
wantText: "POINT (1 -1)",
|
||||
},
|
||||
{
|
||||
name: "antimeridian-east",
|
||||
lat: +0.0,
|
||||
lng: +180.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "+0° +180°",
|
||||
wantText: "POINT (180 0)",
|
||||
},
|
||||
{
|
||||
name: "antimeridian-west",
|
||||
lat: +0.0,
|
||||
lng: -180.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "+0° +180°",
|
||||
wantText: "POINT (180 0)",
|
||||
},
|
||||
{
|
||||
name: "almost-antimeridian-east",
|
||||
lat: +0.0,
|
||||
lng: +179.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +179.0,
|
||||
wantString: "+0° +179°",
|
||||
wantText: "POINT (179 0)",
|
||||
},
|
||||
{
|
||||
name: "past-antimeridian-east",
|
||||
lat: +0.0,
|
||||
lng: +181.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: -179.0,
|
||||
wantString: "+0° -179°",
|
||||
wantText: "POINT (-179 0)",
|
||||
},
|
||||
{
|
||||
name: "almost-antimeridian-west",
|
||||
lat: +0.0,
|
||||
lng: -179.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: -179.0,
|
||||
wantString: "+0° -179°",
|
||||
wantText: "POINT (-179 0)",
|
||||
},
|
||||
{
|
||||
name: "past-antimeridian-west",
|
||||
lat: +0.0,
|
||||
lng: -181.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +179.0,
|
||||
wantString: "+0° +179°",
|
||||
wantText: "POINT (179 0)",
|
||||
},
|
||||
{
|
||||
name: "montreal",
|
||||
lat: +45.508888,
|
||||
lng: -73.561668,
|
||||
wantLat: +45.508888,
|
||||
wantLng: -73.561668,
|
||||
wantString: "+45.508888° -73.561668°",
|
||||
wantText: "POINT (-73.561668 45.508888)",
|
||||
},
|
||||
{
|
||||
name: "canada",
|
||||
lat: 57.550480044655636,
|
||||
lng: -98.41680517868062,
|
||||
wantLat: 57.550480044655636,
|
||||
wantLng: -98.41680517868062,
|
||||
wantString: "+57.550480044655636° -98.41680517868062°",
|
||||
wantText: "POINT (-98.41680517868062 57.550480044655636)",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := geo.MakePoint(tt.lat, tt.lng)
|
||||
|
||||
lat, lng, err := p.LatLng()
|
||||
if !approx(lat, tt.wantLat) {
|
||||
t.Errorf("MakePoint: lat %v, want %v", lat, tt.wantLat)
|
||||
}
|
||||
if !approx(lng, tt.wantLng) {
|
||||
t.Errorf("MakePoint: lng %v, want %v", lng, tt.wantLng)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("LatLng: err %q, expected nil", err)
|
||||
}
|
||||
|
||||
if got := p.String(); got != tt.wantString {
|
||||
t.Errorf("String: got %q, wantString %q", got, tt.wantString)
|
||||
}
|
||||
|
||||
txt, err := p.MarshalText()
|
||||
if err != nil {
|
||||
t.Errorf("Text: err %q, expected nil", err)
|
||||
} else if string(txt) != tt.wantText {
|
||||
t.Errorf("Text: got %q, wantText %q", txt, tt.wantText)
|
||||
}
|
||||
|
||||
b, err := p.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalBinary: err %q, expected nil", err)
|
||||
}
|
||||
|
||||
var q geo.Point
|
||||
if err := q.UnmarshalBinary(b); err != nil {
|
||||
t.Fatalf("UnmarshalBinary: err %q, expected nil", err)
|
||||
}
|
||||
if !q.EqualApprox(p, -1) {
|
||||
t.Errorf("UnmarshalBinary: roundtrip failed: %#v != %#v", q, p)
|
||||
}
|
||||
|
||||
i, err := p.MarshalUint64()
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalUint64: err %q, expected nil", err)
|
||||
}
|
||||
|
||||
var r geo.Point
|
||||
if err := r.UnmarshalUint64(i); err != nil {
|
||||
t.Fatalf("UnmarshalUint64: err %r, expected nil", err)
|
||||
}
|
||||
if !q.EqualApprox(r, -1) {
|
||||
t.Errorf("UnmarshalUint64: roundtrip failed: %#v != %#v", r, p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointMarshalBinary(t *testing.T) {
|
||||
roundtrip := func(p geo.Point) error {
|
||||
b, err := p.MarshalBinary()
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %v", err)
|
||||
}
|
||||
var q geo.Point
|
||||
if err := q.UnmarshalBinary(b); err != nil {
|
||||
return fmt.Errorf("unmarshal: %v", err)
|
||||
}
|
||||
if q != p {
|
||||
return fmt.Errorf("%#v != %#v", q, p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Run("nowhere", func(t *testing.T) {
|
||||
var nowhere geo.Point
|
||||
if err := roundtrip(nowhere); err != nil {
|
||||
t.Errorf("roundtrip: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("quick-check", func(t *testing.T) {
|
||||
f := func(lat geo.Degrees, lng geo.Degrees) (ok bool) {
|
||||
pt := geo.MakePoint(lat, lng)
|
||||
if err := roundtrip(pt); err != nil {
|
||||
t.Errorf("roundtrip: %v", err)
|
||||
}
|
||||
return !t.Failed()
|
||||
}
|
||||
if err := quick.Check(f, nil); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPointMarshalUint64(t *testing.T) {
|
||||
t.Skip("skip")
|
||||
roundtrip := func(p geo.Point) error {
|
||||
i, err := p.MarshalUint64()
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %v", err)
|
||||
}
|
||||
var q geo.Point
|
||||
if err := q.UnmarshalUint64(i); err != nil {
|
||||
return fmt.Errorf("unmarshal: %v", err)
|
||||
}
|
||||
if q != p {
|
||||
return fmt.Errorf("%#v != %#v", q, p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Run("nowhere", func(t *testing.T) {
|
||||
var nowhere geo.Point
|
||||
if err := roundtrip(nowhere); err != nil {
|
||||
t.Errorf("roundtrip: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("quick-check", func(t *testing.T) {
|
||||
f := func(lat geo.Degrees, lng geo.Degrees) (ok bool) {
|
||||
if err := roundtrip(geo.MakePoint(lat, lng)); err != nil {
|
||||
t.Errorf("roundtrip: %v", err)
|
||||
}
|
||||
return !t.Failed()
|
||||
}
|
||||
if err := quick.Check(f, nil); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPointSphericalAngleTo(t *testing.T) {
|
||||
const earthRadius = 6371.000 // volumetric mean radius (km)
|
||||
const kmToRad = 1 / earthRadius
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
x geo.Point
|
||||
y geo.Point
|
||||
want geo.Radians
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "same-point-null-island",
|
||||
x: geo.MakePoint(0, 0),
|
||||
y: geo.MakePoint(0, 0),
|
||||
want: 0.0 * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "same-point-north-pole",
|
||||
x: geo.MakePoint(+90, 0),
|
||||
y: geo.MakePoint(+90, +90),
|
||||
want: 0.0 * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "same-point-south-pole",
|
||||
x: geo.MakePoint(-90, 0),
|
||||
y: geo.MakePoint(-90, -90),
|
||||
want: 0.0 * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "north-pole-to-south-pole",
|
||||
x: geo.MakePoint(+90, 0),
|
||||
y: geo.MakePoint(-90, -90),
|
||||
want: math.Pi * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "toronto-to-montreal",
|
||||
x: geo.MakePoint(+43.6532, -79.3832),
|
||||
y: geo.MakePoint(+45.5019, -73.5674),
|
||||
want: 504.26 * kmToRad * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "sydney-to-san-francisco",
|
||||
x: geo.MakePoint(-33.8727, +151.2057),
|
||||
y: geo.MakePoint(+37.7749, -122.4194),
|
||||
want: 11948.18 * kmToRad * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "new-york-to-paris",
|
||||
x: geo.MakePoint(+40.7128, -74.0060),
|
||||
y: geo.MakePoint(+48.8575, +2.3514),
|
||||
want: 5837.15 * kmToRad * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "seattle-to-tokyo",
|
||||
x: geo.MakePoint(+47.6061, -122.3328),
|
||||
y: geo.MakePoint(+35.6764, +139.6500),
|
||||
want: 7700.00 * kmToRad * geo.Radian,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.x.SphericalAngleTo(tt.y)
|
||||
if tt.wantErr == "" && err != nil {
|
||||
t.Fatalf("err %q, expected nil", err)
|
||||
}
|
||||
if tt.wantErr != "" && (err == nil || err.Error() != tt.wantErr) {
|
||||
t.Fatalf("err %q, expected %q", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr != "" {
|
||||
return
|
||||
}
|
||||
|
||||
if !approx(got, tt.want) {
|
||||
t.Errorf("x to y: got %v, want %v", got, tt.want)
|
||||
}
|
||||
|
||||
// Distance should be commutative
|
||||
got, err = tt.y.SphericalAngleTo(tt.x)
|
||||
if err != nil {
|
||||
t.Fatalf("err %q, expected nil", err)
|
||||
}
|
||||
if !approx(got, tt.want) {
|
||||
t.Errorf("y to x: got %v, want %v", got, tt.want)
|
||||
}
|
||||
t.Logf("x to y: %v km", got/kmToRad)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func approx[T ~float64](x, y T) bool {
|
||||
return math.Abs(float64(x)-float64(y)) <= 1e-5
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package geo_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/quick"
|
||||
|
||||
"tailscale.com/types/geo"
|
||||
)
|
||||
|
||||
func TestPointAnonymize(t *testing.T) {
|
||||
t.Run("nowhere", func(t *testing.T) {
|
||||
var zero geo.Point
|
||||
p := zero.Quantize()
|
||||
want := zero.Valid()
|
||||
if got := p.Valid(); got != want {
|
||||
t.Fatalf("zero.Valid %t, want %t", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("separation", func(t *testing.T) {
|
||||
// Walk from the south pole to the north pole and check that each
|
||||
// point on the latitude is approximately MinSeparation apart.
|
||||
const southPole = -90 * geo.Degree
|
||||
const northPole = 90 * geo.Degree
|
||||
const dateLine = 180 * geo.Degree
|
||||
|
||||
llat := southPole
|
||||
for lat := llat; lat <= northPole; lat += 0x1p-4 {
|
||||
last := geo.MakePoint(llat, 0)
|
||||
cur := geo.MakePoint(lat, 0)
|
||||
anon := cur.Quantize()
|
||||
switch l, g, err := anon.LatLng(); {
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
case lat == southPole:
|
||||
// initialize llng, to the first snapped longitude
|
||||
llat = l
|
||||
goto Lng
|
||||
case g != 0:
|
||||
t.Fatalf("%v is west or east of %v", anon, last)
|
||||
case l < llat:
|
||||
t.Fatalf("%v is south of %v", anon, last)
|
||||
case l == llat:
|
||||
continue
|
||||
case l > llat:
|
||||
switch dist, err := last.DistanceTo(anon); {
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
case dist == 0.0:
|
||||
continue
|
||||
case dist < geo.MinSeparation:
|
||||
t.Logf("lat=%v last=%v cur=%v anon=%v", lat, last, cur, anon)
|
||||
t.Fatalf("%v is too close to %v", anon, last)
|
||||
default:
|
||||
llat = l
|
||||
}
|
||||
}
|
||||
|
||||
Lng:
|
||||
llng := dateLine
|
||||
for lng := llng; lng <= dateLine && lng >= -dateLine; lng -= 0x1p-3 {
|
||||
last := geo.MakePoint(llat, llng)
|
||||
cur := geo.MakePoint(lat, lng)
|
||||
anon := cur.Quantize()
|
||||
switch l, g, err := anon.LatLng(); {
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
case lng == dateLine:
|
||||
// initialize llng, to the first snapped longitude
|
||||
llng = g
|
||||
continue
|
||||
case l != llat:
|
||||
t.Fatalf("%v is north or south of %v", anon, last)
|
||||
case g != llng:
|
||||
const tolerance = geo.MinSeparation * 0x1p-9
|
||||
switch dist, err := last.DistanceTo(anon); {
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
case dist < tolerance:
|
||||
continue
|
||||
case dist < (geo.MinSeparation - tolerance):
|
||||
t.Logf("lat=%v lng=%v last=%v cur=%v anon=%v", lat, lng, last, cur, anon)
|
||||
t.Fatalf("%v is too close to %v: %v", anon, last, dist)
|
||||
default:
|
||||
llng = g
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
if llat == southPole {
|
||||
t.Fatal("llat never incremented")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("quick-check", func(t *testing.T) {
|
||||
f := func(lat, lng geo.Degrees) bool {
|
||||
p := geo.MakePoint(lat, lng)
|
||||
q := p.Quantize()
|
||||
t.Logf("quantize %v = %v", p, q)
|
||||
|
||||
lat, lng, err := q.LatLng()
|
||||
if err != nil {
|
||||
t.Errorf("err %v, want nil", err)
|
||||
return !t.Failed()
|
||||
}
|
||||
|
||||
if lat < -90*geo.Degree || lat > 90*geo.Degree {
|
||||
t.Errorf("lat outside [-90°, +90°]: %v", lat)
|
||||
}
|
||||
if lng < -180*geo.Degree || lng > 180*geo.Degree {
|
||||
t.Errorf("lng outside [-180°, +180°], %v", lng)
|
||||
}
|
||||
|
||||
if dist, err := p.DistanceTo(q); err != nil {
|
||||
t.Error(err)
|
||||
} else if dist > (geo.MinSeparation * 2) {
|
||||
t.Errorf("moved too far: %v", dist)
|
||||
}
|
||||
|
||||
return !t.Failed()
|
||||
}
|
||||
if err := quick.Check(f, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,191 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package geo
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
Degree Degrees = 1
|
||||
Radian Radians = 1
|
||||
Turn Turns = 1
|
||||
Meter Distance = 1
|
||||
)
|
||||
|
||||
// Degrees represents a latitude or longitude, in decimal degrees.
|
||||
type Degrees float64
|
||||
|
||||
// ParseDegrees parses s as decimal degrees.
|
||||
func ParseDegrees(s string) (Degrees, error) {
|
||||
s = strings.TrimSuffix(s, "°")
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
return Degrees(f), err
|
||||
}
|
||||
|
||||
// MustParseDegrees parses s as decimal degrees, but panics on error.
|
||||
func MustParseDegrees(s string) Degrees {
|
||||
d, err := ParseDegrees(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// String implements the [Stringer] interface. The output is formatted in
|
||||
// decimal degrees, prefixed by either the appropriate + or - sign, and suffixed
|
||||
// by a ° degree symbol.
|
||||
func (d Degrees) String() string {
|
||||
b, _ := d.AppendText(nil)
|
||||
b = append(b, []byte("°")...)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// AppendText implements [encoding.TextAppender]. The output is formatted in
|
||||
// decimal degrees, prefixed by either the appropriate + or - sign.
|
||||
func (d Degrees) AppendText(b []byte) ([]byte, error) {
|
||||
b = d.AppendZeroPaddedText(b, 0)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// AppendZeroPaddedText appends d formatted as decimal degrees to b. The number of
|
||||
// integer digits will be zero-padded to nint.
|
||||
func (d Degrees) AppendZeroPaddedText(b []byte, nint int) []byte {
|
||||
n := float64(d)
|
||||
|
||||
if math.IsInf(n, 0) || math.IsNaN(n) {
|
||||
return strconv.AppendFloat(b, n, 'f', -1, 64)
|
||||
}
|
||||
|
||||
sign := byte('+')
|
||||
if math.Signbit(n) {
|
||||
sign = '-'
|
||||
n = -n
|
||||
}
|
||||
b = append(b, sign)
|
||||
|
||||
pad := nint - 1
|
||||
for nn := n / 10; nn >= 1 && pad > 0; nn /= 10 {
|
||||
pad--
|
||||
}
|
||||
for range pad {
|
||||
b = append(b, '0')
|
||||
}
|
||||
return strconv.AppendFloat(b, n, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// Radians converts d into radians.
|
||||
func (d Degrees) Radians() Radians {
|
||||
return Radians(d * math.Pi / 180.0)
|
||||
}
|
||||
|
||||
// Turns converts d into a number of turns.
|
||||
func (d Degrees) Turns() Turns {
|
||||
return Turns(d / 360.0)
|
||||
}
|
||||
|
||||
// Radians represents a latitude or longitude, in radians.
|
||||
type Radians float64
|
||||
|
||||
// ParseRadians parses s as radians.
|
||||
func ParseRadians(s string) (Radians, error) {
|
||||
s = strings.TrimSuffix(s, "rad")
|
||||
s = strings.TrimRightFunc(s, unicode.IsSpace)
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
return Radians(f), err
|
||||
}
|
||||
|
||||
// MustParseRadians parses s as radians, but panics on error.
|
||||
func MustParseRadians(s string) Radians {
|
||||
r, err := ParseRadians(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// String implements the [Stringer] interface.
|
||||
func (r Radians) String() string {
|
||||
return strconv.FormatFloat(float64(r), 'f', -1, 64) + " rad"
|
||||
}
|
||||
|
||||
// Degrees converts r into decimal degrees.
|
||||
func (r Radians) Degrees() Degrees {
|
||||
return Degrees(r * 180.0 / math.Pi)
|
||||
}
|
||||
|
||||
// Turns converts r into a number of turns.
|
||||
func (r Radians) Turns() Turns {
|
||||
return Turns(r / 2 / math.Pi)
|
||||
}
|
||||
|
||||
// Turns represents a number of complete revolutions around a sphere.
|
||||
type Turns float64
|
||||
|
||||
// String implements the [Stringer] interface.
|
||||
func (o Turns) String() string {
|
||||
return strconv.FormatFloat(float64(o), 'f', -1, 64)
|
||||
}
|
||||
|
||||
// Degrees converts t into decimal degrees.
|
||||
func (o Turns) Degrees() Degrees {
|
||||
return Degrees(o * 360.0)
|
||||
}
|
||||
|
||||
// Radians converts t into radians.
|
||||
func (o Turns) Radians() Radians {
|
||||
return Radians(o * 2 * math.Pi)
|
||||
}
|
||||
|
||||
// Distance represents a great-circle distance in meters.
|
||||
type Distance float64
|
||||
|
||||
// ParseDistance parses s as distance in meters.
|
||||
func ParseDistance(s string) (Distance, error) {
|
||||
s = strings.TrimSuffix(s, "m")
|
||||
s = strings.TrimRightFunc(s, unicode.IsSpace)
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
return Distance(f), err
|
||||
}
|
||||
|
||||
// MustParseDistance parses s as distance in meters, but panics on error.
|
||||
func MustParseDistance(s string) Distance {
|
||||
d, err := ParseDistance(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// String implements the [Stringer] interface.
|
||||
func (d Distance) String() string {
|
||||
return strconv.FormatFloat(float64(d), 'f', -1, 64) + "m"
|
||||
}
|
||||
|
||||
// DistanceOnEarth converts t turns into the great-circle distance, in meters.
|
||||
func DistanceOnEarth(t Turns) Distance {
|
||||
return Distance(t) * EarthMeanCircumference
|
||||
}
|
||||
|
||||
// Earth Fact Sheet
|
||||
// https://nssdc.gsfc.nasa.gov/planetary/factsheet/earthfact.html
|
||||
const (
|
||||
// EarthMeanRadius is the volumetric mean radius of the Earth.
|
||||
EarthMeanRadius = 6_371_000 * Meter
|
||||
// EarthMeanCircumference is the volumetric mean circumference of the Earth.
|
||||
EarthMeanCircumference = 2 * math.Pi * EarthMeanRadius
|
||||
|
||||
// earthEquatorialRadius is the equatorial radius of the Earth.
|
||||
earthEquatorialRadius = 6_378_137 * Meter
|
||||
// earthEquatorialCircumference is the equatorial circumference of the Earth.
|
||||
earthEquatorialCircumference = 2 * math.Pi * earthEquatorialRadius
|
||||
|
||||
// earthPolarRadius is the polar radius of the Earth.
|
||||
earthPolarRadius = 6_356_752 * Meter
|
||||
// earthPolarCircumference is the polar circumference of the Earth.
|
||||
earthPolarCircumference = 2 * math.Pi * earthPolarRadius
|
||||
)
|
||||
@ -0,0 +1,395 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package geo_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/types/geo"
|
||||
)
|
||||
|
||||
func TestDegrees(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
degs geo.Degrees
|
||||
wantStr string
|
||||
wantText string
|
||||
wantPad string
|
||||
wantRads geo.Radians
|
||||
wantTurns geo.Turns
|
||||
}{
|
||||
{
|
||||
name: "zero",
|
||||
degs: 0.0 * geo.Degree,
|
||||
wantStr: "+0°",
|
||||
wantText: "+0",
|
||||
wantPad: "+000",
|
||||
wantRads: 0.0 * geo.Radian,
|
||||
wantTurns: 0 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "quarter-turn",
|
||||
degs: 90.0 * geo.Degree,
|
||||
wantStr: "+90°",
|
||||
wantText: "+90",
|
||||
wantPad: "+090",
|
||||
wantRads: 0.5 * math.Pi * geo.Radian,
|
||||
wantTurns: 0.25 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "half-turn",
|
||||
degs: 180.0 * geo.Degree,
|
||||
wantStr: "+180°",
|
||||
wantText: "+180",
|
||||
wantPad: "+180",
|
||||
wantRads: 1.0 * math.Pi * geo.Radian,
|
||||
wantTurns: 0.5 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "full-turn",
|
||||
degs: 360.0 * geo.Degree,
|
||||
wantStr: "+360°",
|
||||
wantText: "+360",
|
||||
wantPad: "+360",
|
||||
wantRads: 2.0 * math.Pi * geo.Radian,
|
||||
wantTurns: 1.0 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "negative-zero",
|
||||
degs: geo.MustParseDegrees("-0.0"),
|
||||
wantStr: "-0°",
|
||||
wantText: "-0",
|
||||
wantPad: "-000",
|
||||
wantRads: 0 * geo.Radian * -1,
|
||||
wantTurns: 0 * geo.Turn * -1,
|
||||
},
|
||||
{
|
||||
name: "small-degree",
|
||||
degs: -1.2003 * geo.Degree,
|
||||
wantStr: "-1.2003°",
|
||||
wantText: "-1.2003",
|
||||
wantPad: "-001.2003",
|
||||
wantRads: -0.020949187011687936 * geo.Radian,
|
||||
wantTurns: -0.0033341666666666663 * geo.Turn,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.degs.String(); got != tt.wantStr {
|
||||
t.Errorf("String got %q, want %q", got, tt.wantStr)
|
||||
}
|
||||
|
||||
d, err := geo.ParseDegrees(tt.wantStr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDegrees err %q, want nil", err.Error())
|
||||
}
|
||||
if d != tt.degs {
|
||||
t.Errorf("ParseDegrees got %q, want %q", d, tt.degs)
|
||||
}
|
||||
|
||||
b, err := tt.degs.AppendText(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AppendText err %q, want nil", err.Error())
|
||||
}
|
||||
if string(b) != tt.wantText {
|
||||
t.Errorf("AppendText got %q, want %q", b, tt.wantText)
|
||||
}
|
||||
|
||||
b = tt.degs.AppendZeroPaddedText(nil, 3)
|
||||
if string(b) != tt.wantPad {
|
||||
t.Errorf("AppendZeroPaddedText got %q, want %q", b, tt.wantPad)
|
||||
}
|
||||
|
||||
r := tt.degs.Radians()
|
||||
if r != tt.wantRads {
|
||||
t.Errorf("Radian got %v, want %v", r, tt.wantRads)
|
||||
}
|
||||
if d := r.Degrees(); d != tt.degs { // Roundtrip
|
||||
t.Errorf("Degrees got %v, want %v", d, tt.degs)
|
||||
}
|
||||
|
||||
o := tt.degs.Turns()
|
||||
if o != tt.wantTurns {
|
||||
t.Errorf("Turns got %v, want %v", o, tt.wantTurns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadians(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
rads geo.Radians
|
||||
wantStr string
|
||||
wantText string
|
||||
wantDegs geo.Degrees
|
||||
wantTurns geo.Turns
|
||||
}{
|
||||
{
|
||||
name: "zero",
|
||||
rads: 0.0 * geo.Radian,
|
||||
wantStr: "0 rad",
|
||||
wantDegs: 0.0 * geo.Degree,
|
||||
wantTurns: 0 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "quarter-turn",
|
||||
rads: 0.5 * math.Pi * geo.Radian,
|
||||
wantStr: "1.5707963267948966 rad",
|
||||
wantDegs: 90.0 * geo.Degree,
|
||||
wantTurns: 0.25 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "half-turn",
|
||||
rads: 1.0 * math.Pi * geo.Radian,
|
||||
wantStr: "3.141592653589793 rad",
|
||||
wantDegs: 180.0 * geo.Degree,
|
||||
wantTurns: 0.5 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "full-turn",
|
||||
rads: 2.0 * math.Pi * geo.Radian,
|
||||
wantStr: "6.283185307179586 rad",
|
||||
wantDegs: 360.0 * geo.Degree,
|
||||
wantTurns: 1.0 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "negative-zero",
|
||||
rads: geo.MustParseRadians("-0"),
|
||||
wantStr: "-0 rad",
|
||||
wantDegs: 0 * geo.Degree * -1,
|
||||
wantTurns: 0 * geo.Turn * -1,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.rads.String(); got != tt.wantStr {
|
||||
t.Errorf("String got %q, want %q", got, tt.wantStr)
|
||||
}
|
||||
|
||||
r, err := geo.ParseRadians(tt.wantStr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDegrees err %q, want nil", err.Error())
|
||||
}
|
||||
if r != tt.rads {
|
||||
t.Errorf("ParseDegrees got %q, want %q", r, tt.rads)
|
||||
}
|
||||
|
||||
d := tt.rads.Degrees()
|
||||
if d != tt.wantDegs {
|
||||
t.Errorf("Degrees got %v, want %v", d, tt.wantDegs)
|
||||
}
|
||||
if r := d.Radians(); r != tt.rads { // Roundtrip
|
||||
t.Errorf("Radians got %v, want %v", r, tt.rads)
|
||||
}
|
||||
|
||||
o := tt.rads.Turns()
|
||||
if o != tt.wantTurns {
|
||||
t.Errorf("Turns got %v, want %v", o, tt.wantTurns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurns(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
turns geo.Turns
|
||||
wantStr string
|
||||
wantText string
|
||||
wantDegs geo.Degrees
|
||||
wantRads geo.Radians
|
||||
}{
|
||||
{
|
||||
name: "zero",
|
||||
turns: 0.0,
|
||||
wantStr: "0",
|
||||
wantDegs: 0.0 * geo.Degree,
|
||||
wantRads: 0 * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "quarter-turn",
|
||||
turns: 0.25,
|
||||
wantStr: "0.25",
|
||||
wantDegs: 90.0 * geo.Degree,
|
||||
wantRads: 0.5 * math.Pi * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "half-turn",
|
||||
turns: 0.5,
|
||||
wantStr: "0.5",
|
||||
wantDegs: 180.0 * geo.Degree,
|
||||
wantRads: 1.0 * math.Pi * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "full-turn",
|
||||
turns: 1.0,
|
||||
wantStr: "1",
|
||||
wantDegs: 360.0 * geo.Degree,
|
||||
wantRads: 2.0 * math.Pi * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "negative-zero",
|
||||
turns: geo.Turns(math.Copysign(0, -1)),
|
||||
wantStr: "-0",
|
||||
wantDegs: 0 * geo.Degree * -1,
|
||||
wantRads: 0 * geo.Radian * -1,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.turns.String(); got != tt.wantStr {
|
||||
t.Errorf("String got %q, want %q", got, tt.wantStr)
|
||||
}
|
||||
|
||||
d := tt.turns.Degrees()
|
||||
if d != tt.wantDegs {
|
||||
t.Errorf("Degrees got %v, want %v", d, tt.wantDegs)
|
||||
}
|
||||
if o := d.Turns(); o != tt.turns { // Roundtrip
|
||||
t.Errorf("Turns got %v, want %v", o, tt.turns)
|
||||
}
|
||||
|
||||
r := tt.turns.Radians()
|
||||
if r != tt.wantRads {
|
||||
t.Errorf("Turns got %v, want %v", r, tt.wantRads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistance(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
dist geo.Distance
|
||||
wantStr string
|
||||
}{
|
||||
{
|
||||
name: "zero",
|
||||
dist: 0.0 * geo.Meter,
|
||||
wantStr: "0m",
|
||||
},
|
||||
{
|
||||
name: "random",
|
||||
dist: 4 * geo.Meter,
|
||||
wantStr: "4m",
|
||||
},
|
||||
{
|
||||
name: "light-second",
|
||||
dist: 299_792_458 * geo.Meter,
|
||||
wantStr: "299792458m",
|
||||
},
|
||||
{
|
||||
name: "planck-length",
|
||||
dist: 1.61625518e-35 * geo.Meter,
|
||||
wantStr: "0.0000000000000000000000000000000000161625518m",
|
||||
},
|
||||
{
|
||||
name: "negative-zero",
|
||||
dist: geo.Distance(math.Copysign(0, -1)),
|
||||
wantStr: "-0m",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.dist.String(); got != tt.wantStr {
|
||||
t.Errorf("String got %q, want %q", got, tt.wantStr)
|
||||
}
|
||||
|
||||
r, err := geo.ParseDistance(tt.wantStr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDegrees err %q, want nil", err.Error())
|
||||
}
|
||||
if r != tt.dist {
|
||||
t.Errorf("ParseDegrees got %q, want %q", r, tt.dist)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistanceOnEarth(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
here geo.Point
|
||||
there geo.Point
|
||||
want geo.Distance
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "no-points",
|
||||
here: geo.Point{},
|
||||
there: geo.Point{},
|
||||
wantErr: "not a valid point",
|
||||
},
|
||||
{
|
||||
name: "not-here",
|
||||
here: geo.Point{},
|
||||
there: geo.MakePoint(0, 0),
|
||||
wantErr: "not a valid point",
|
||||
},
|
||||
{
|
||||
name: "not-there",
|
||||
here: geo.MakePoint(0, 0),
|
||||
there: geo.Point{},
|
||||
wantErr: "not a valid point",
|
||||
},
|
||||
{
|
||||
name: "null-island",
|
||||
here: geo.MakePoint(0, 0),
|
||||
there: geo.MakePoint(0, 0),
|
||||
want: 0 * geo.Meter,
|
||||
},
|
||||
{
|
||||
name: "equator-to-south-pole",
|
||||
here: geo.MakePoint(0, 0),
|
||||
there: geo.MakePoint(-90, 0),
|
||||
want: geo.EarthMeanCircumference / 4,
|
||||
},
|
||||
{
|
||||
name: "north-pole-to-south-pole",
|
||||
here: geo.MakePoint(+90, 0),
|
||||
there: geo.MakePoint(-90, 0),
|
||||
want: geo.EarthMeanCircumference / 2,
|
||||
},
|
||||
{
|
||||
name: "meridian-to-antimeridian",
|
||||
here: geo.MakePoint(0, 0),
|
||||
there: geo.MakePoint(0, -180),
|
||||
want: geo.EarthMeanCircumference / 2,
|
||||
},
|
||||
{
|
||||
name: "positive-to-negative-antimeridian",
|
||||
here: geo.MakePoint(0, 180),
|
||||
there: geo.MakePoint(0, -180),
|
||||
want: 0 * geo.Meter,
|
||||
},
|
||||
{
|
||||
name: "toronto-to-montreal",
|
||||
here: geo.MakePoint(+43.70011, -79.41630),
|
||||
there: geo.MakePoint(+45.50884, -73.58781),
|
||||
want: 503_200 * geo.Meter,
|
||||
},
|
||||
{
|
||||
name: "montreal-to-san-francisco",
|
||||
here: geo.MakePoint(+45.50884, -73.58781),
|
||||
there: geo.MakePoint(+37.77493, -122.41942),
|
||||
want: 4_082_600 * geo.Meter,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.here.DistanceTo(tt.there)
|
||||
if tt.wantErr == "" && err != nil {
|
||||
t.Fatalf("err %q, want nil", err)
|
||||
}
|
||||
if tt.wantErr != "" && !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("err %q, want %q", err, tt.wantErr)
|
||||
}
|
||||
|
||||
approx := func(x, y geo.Distance) bool {
|
||||
return math.Abs(float64(x)-float64(y)) <= 10
|
||||
}
|
||||
if !approx(got, tt.want) {
|
||||
t.Fatalf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue