From 936e7d38cb7df391991212df9bb2b5b299063760 Mon Sep 17 00:00:00 2001 From: dwrz Date: Fri, 23 Oct 2026 14:10:22 +0000 Subject: [PATCH] Add geo pkg --- pkg/geo/geo.go | 34 ++++++++ pkg/geo/geo_test.go | 183 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 pkg/geo/geo.go create mode 100644 pkg/geo/geo_test.go diff --git a/pkg/geo/geo.go b/pkg/geo/geo.go new file mode 100644 index 0000000..c3efb8b --- /dev/null +++ b/pkg/geo/geo.go @@ -0,0 +1,34 @@ +package geo + +import ( + "fmt" + "strconv" + "strings" +) + +type Coordinates struct { + Latitude float64 + Longitude float64 +} + +func ParseCoordinates(s string) (*Coordinates, error) { + parts := strings.Split(s, ",") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid string: %s", s) + } + + var ( + c = &Coordinates{} + err error + ) + c.Latitude, err = strconv.ParseFloat(parts[0], 64) + if err != nil { + return nil, fmt.Errorf("failed to parse latitude: %v", err) + } + c.Longitude, err = strconv.ParseFloat(parts[1], 64) + if err != nil { + return nil, fmt.Errorf("failed to parse longitude: %v", err) + } + + return c, nil +} diff --git a/pkg/geo/geo_test.go b/pkg/geo/geo_test.go new file mode 100644 index 0000000..121fc92 --- /dev/null +++ b/pkg/geo/geo_test.go @@ -0,0 +1,183 @@ +package geo + +import ( + "math" + "strconv" + "strings" + "testing" +) + +// parseCase holds one input to ParseCoordinates and its expectation. +// expected is nil for inputs that must fail; errPart is then the +// text the error message must contain. +type parseCase struct { + in string + expected *Coordinates + errPart string +} + +func TestParseCoordinates(t *testing.T) { + var cases = []parseCase{ + {in: `52.37,4.90`, expected: &Coordinates{52.37, 4.90}}, + {in: `-33.87,151.21`, expected: &Coordinates{-33.87, 151.21}}, + {in: `1,2`, expected: &Coordinates{1, 2}}, + + {in: `+1.5,-2.5`, expected: &Coordinates{1.5, -2.5}}, + {in: `1e1,2.5e0`, expected: &Coordinates{10, 2.5}}, + {in: `0x1p2,3`, expected: &Coordinates{4, 3}}, + + // ParseCoordinates does not check value ranges. + {in: `inf,1`, expected: &Coordinates{math.Inf(1), 1}}, + + {in: ``, errPart: `invalid string`}, + {in: `52.37`, errPart: `invalid string`}, + {in: `52.37,4.90,1`, errPart: `invalid string`}, + {in: `a,b`, errPart: `latitude`}, + {in: `1,b`, errPart: `longitude`}, + {in: `1,`, errPart: `longitude`}, + {in: `,2`, errPart: `latitude`}, + + // ParseFloat does not skip whitespace. + {in: ` 52.37 , 4.90 `, errPart: `latitude`}, + + // ParseFloat rejects values outside the float64 range. + {in: `1e999,2`, errPart: `latitude`}, + } + + for _, c := range cases { + t.Run(c.in, c.testParseCoordinates) + } +} + +// testParseCoordinates checks the expected result or error for one parse case. +func (c parseCase) testParseCoordinates(t *testing.T) { + got, err := ParseCoordinates(c.in) + + if c.expected == nil { + if err == nil { + t.Fatalf("got %v, want error", got) + } + if got != nil { + t.Fatalf("got %v and error %v, want nil", got, err) + } + if !strings.Contains(err.Error(), c.errPart) { + t.Fatalf("error %q, want %q", err.Error(), c.errPart) + } + return + } + + if err != nil { + t.Fatalf("error = %v", err) + } + if got == nil { + t.Fatalf("got nil, want %v", c.expected) + } + if got.Latitude != c.expected.Latitude { + t.Errorf( + "Latitude = %v, want %v", + got.Latitude, c.expected.Latitude, + ) + } + if got.Longitude != c.expected.Longitude { + t.Errorf( + "Longitude = %v, want %v", + got.Longitude, c.expected.Longitude, + ) + } +} + +// TestParseCoordinatesNaN checks that ParseCoordinates accepts nan. +// It does not check value ranges. +func TestParseCoordinatesNaN(t *testing.T) { + got, err := ParseCoordinates("nan,2") + if err != nil { + t.Fatalf("error = %v", err) + } + if got == nil { + t.Fatal("got nil, want coordinates") + } + if !math.IsNaN(got.Latitude) { + t.Errorf("Latitude = %v, want NaN", got.Latitude) + } + if got.Longitude != 2 { + t.Errorf("Longitude = %v, want 2", got.Longitude) + } +} + +// FuzzParseCoordinates checks that ParseCoordinates handles any input +// without panicking, that it does not return a result and an error +// together, and that a successful result matches strconv.ParseFloat +// on each comma-separated part. +func FuzzParseCoordinates(f *testing.F) { + f.Add(`52.37,4.90`) + f.Add(`-33.8688,151.2093`) + f.Add(`nan,inf`) + f.Add(``) + f.Add(`52.37`) + f.Add(`52.37,4.90,1`) + f.Add(`a,b`) + f.Add(`1,`) + f.Add(`,2`) + f.Add(`1e999,2`) + + f.Fuzz(fuzzParseCoordinates) +} + +func fuzzParseCoordinates(t *testing.T, s string) { + got, err := ParseCoordinates(s) + if err != nil && got != nil { + t.Fatalf( + "ParseCoordinates(%q) = %v with error %v, want nil", + s, got, err, + ) + } + + // The input must hold exactly two comma-separated parts. + parts := strings.Split(s, `,`) + if len(parts) != 2 { + if err == nil { + t.Fatalf( + "ParseCoordinates(%q) = %v, want error", + s, got, + ) + } + return + } + + // Each part must hold a valid float64 value. + expectedLat, latErr := strconv.ParseFloat(parts[0], 64) + expectedLon, lonErr := strconv.ParseFloat(parts[1], 64) + if latErr != nil || lonErr != nil { + if err == nil { + t.Fatalf( + "ParseCoordinates(%q) = %v, want error", + s, got, + ) + } + return + } + + if err != nil { + t.Fatalf("ParseCoordinates(%q) error = %v", s, err) + } + if got == nil { + t.Fatalf("ParseCoordinates(%q) = nil, want coordinates", s) + } + + // Both values come from strconv.ParseFloat. The bit comparison + // reports NaN as equal; the == operator does not. + if math.Float64bits(got.Latitude) != + math.Float64bits(expectedLat) { + t.Fatalf( + "ParseCoordinates(%q).Latitude = %v, want %v", + s, got.Latitude, expectedLat, + ) + } + if math.Float64bits(got.Longitude) != + math.Float64bits(expectedLon) { + t.Fatalf( + "ParseCoordinates(%q).Longitude = %v, want %v", + s, got.Longitude, expectedLon, + ) + } +}