package routes import ( "bytes" "context" "encoding/xml" "fmt" "net/http" "net/http/httptest" "slices" "strings" "sync" "sync/atomic" "testing" ) // line returns a straight path of about km kilometres going east from p. func line(p Point, km float64) []Point { const kmPerDegLon = 70.0 // at ~51°N return []Point{p, {p.Lat, p.Lon + km/kmPerDegLon/2}, {p.Lat, p.Lon + km/kmPerDegLon}} } const overpassJSON = `{"elements":[ {"type":"relation","id":1,"tags":{"route":"foot","name":"Park loop","roundtrip":"yes"}, "members":[{"type":"way","geometry":[{"lat":51.05,"lon":3.70},{"lat":51.05,"lon":3.7357}]}]}, {"type":"relation","id":2,"tags":{"route":"bicycle","name":"River ride"}, "members":[{"type":"way","geometry":[{"lat":51.05,"lon":3.70},{"lat":51.05,"lon":3.9857}]},{"type":"node"}]}, {"type":"relation","id":3,"tags":{"route":"foot"},"members":[]}, {"type":"node","id":9,"lat":51.051,"lon":3.701,"tags":{"rcn_ref":"56"}} ]}` func TestParse(t *testing.T) { a, err := parse(strings.NewReader(overpassJSON)) if err != nil { t.Fatal(err) } if len(a.Routes) != 2 { t.Fatalf("got %d routes, want 2 (unnamed one skipped): %+v", len(a.Routes), a.Routes) } park, ride := a.Routes[0], a.Routes[1] if park.Kind != Foot || !park.Loop || park.LengthKm < 2.4 || park.LengthKm > 2.6 { t.Errorf("park = %+v", park) } if ride.Kind != Bike || ride.Loop || ride.LengthKm < 19.5 || ride.LengthKm > 20.5 { t.Errorf("ride = %+v", ride) } j, km, ok := a.NearestJunction(Bike, Point{51.05, 3.70}) if !ok || j.Ref != "56" || km > 0.2 { t.Errorf("junction = %+v %.2f %v", j, km, ok) } if _, _, ok := a.NearestJunction(Foot, Point{51.05, 3.70}); ok { t.Error("found a walking junction that isn't there") } } func TestSuggest(t *testing.T) { home := Point{51.05, 3.70} a := &Area{Routes: []Route{ {ID: 1, Name: "short loop", Kind: Foot, Loop: true, LengthKm: 5, Ways: [][]Point{line(home, 5)}}, {ID: 2, Name: "perfect loop far away", Kind: Foot, Loop: true, LengthKm: 10, Ways: [][]Point{line(Point{51.2, 3.7}, 10)}}, {ID: 3, Name: "long path", Kind: Foot, LengthKm: 30, Ways: [][]Point{line(home, 30)}}, {ID: 4, Name: "bike loop", Kind: Bike, Loop: true, LengthKm: 10, Ways: [][]Point{line(home, 10)}}, }} got := Suggest(a, Foot, 10, home, 3) if len(got) != 2 { t.Fatalf("got %d suggestions, want 2 (one too far, one for bikes): %+v", len(got), got) } if got[0].Route.ID != 1 || got[0].Plan != "laps" || got[0].Laps != 2 || got[0].TotalKm != 10 { t.Errorf("first = %+v, want 2 laps of the short loop", got[0]) } if got[1].Plan != "section" || got[1].TotalKm != 10 { t.Errorf("second = %+v, want a 10 km section", got[1]) } if s := got[0].Describe(); s != "2 laps of the 5.0 km loop" { t.Errorf("describe = %q", s) } } func TestNearRetriesAndCaches(t *testing.T) { var calls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Accept") != "application/json" { http.Error(w, "406", http.StatusNotAcceptable) return } if calls.Add(1) == 1 { http.Error(w, "busy", http.StatusGatewayTimeout) return } fmt.Fprint(w, overpassJSON) })) defer srv.Close() dir := t.TempDir() c := &Client{URLs: []string{srv.URL}, CacheDir: dir} a, err := c.Near(context.Background(), 51.05, 3.70) if err != nil || len(a.Routes) != 2 { t.Fatalf("Near = %v, %v", a, err) } if _, ok := c.Route(2); !ok { t.Error("route 2 not remembered for GPX") } // Same ~2 km cell: no new request. A fresh client reads the disk cache. c.Near(context.Background(), 51.051, 3.701) (&Client{URLs: []string{srv.URL}, CacheDir: dir}).Near(context.Background(), 51.05, 3.70) if n := calls.Load(); n != 2 { t.Errorf("overpass called %d times, want 2 (one busy, one ok)", n) } } func TestWriteGPX(t *testing.T) { var buf bytes.Buffer r := &Route{Name: "Park & loop", Ways: [][]Point{{{51, 3}, {51.1, 3.1}}, {{51.2, 3.2}, {51.3, 3.3}}}} if err := WriteGPX(&buf, r); err != nil { t.Fatal(err) } var g struct { Name string `xml:"trk>name"` Segs []struct { Pts []struct{} `xml:"trkpt"` } `xml:"trk>trkseg"` } if err := xml.Unmarshal(buf.Bytes(), &g); err != nil { t.Fatal(err, buf.String()) } if g.Name != "Park & loop" || len(g.Segs) != 2 || len(g.Segs[1].Pts) != 2 { t.Errorf("gpx = %+v\n%s", g, buf.String()) } } func TestLoops(t *testing.T) { var dirs []string var mu sync.Mutex srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() mu.Lock() dirs = append(dirs, q.Get("direction")) mu.Unlock() if q.Get("engineMode") != "4" || q.Get("profile") != "hiking-mountain" || q.Get("lonlats") != "4.700,50.880" { http.Error(w, "bad query "+r.URL.RawQuery, http.StatusBadRequest) return } if q.Get("roundTripDistance") != "1960" { // 10 km / 5.1 http.Error(w, "bad distance "+q.Get("roundTripDistance"), http.StatusBadRequest) return } if q.Get("direction") == "330" { http.Error(w, "no route", http.StatusInternalServerError) // one direction failing is fine return } http.ServeFile(w, r, "testdata/brouter.json") })) defer srv.Close() c := &Client{BRouterURL: srv.URL} // The exact spot is rounded to ~100 m before it's sent. got, err := c.Loops(context.Background(), Foot, Point{50.88012, 4.70044}, 10, 90) if err != nil { t.Fatal(err) } slices.Sort(dirs) if !slices.Equal(dirs, []string{"210", "330", "90"}) { t.Errorf("directions asked = %v, want into the wind (90) and ±120°", dirs) } if len(got) != 2 { t.Fatalf("got %d loops, want 2", len(got)) } r := got[0].Route if !r.Generated || !r.Loop || r.LengthKm != 5.9 || r.AscentM != 17 || r.PavedPct < 0 || r.PavedPct > 100 || r.ID >= 0 { t.Errorf("loop = %+v", r) } if !strings.HasPrefix(r.Name, "5.9 km loop heading ") { t.Errorf("name = %q", r.Name) } if _, ok := c.Route(r.ID); !ok { t.Error("loop not remembered for GPX") } n := len(dirs) c.Loops(context.Background(), Foot, Point{50.88012, 4.70044}, 10, 90) if len(dirs) != n { t.Error("second identical request should hit the cache") } } func TestPavedPct(t *testing.T) { msgs := [][]string{ {"Longitude", "Latitude", "Elevation", "Distance", "WayTags"}, {"0", "0", "0", "300", "highway=residential"}, {"0", "0", "0", "100", "highway=track surface=asphalt"}, {"0", "0", "0", "400", "highway=path"}, {"0", "0", "0", "200", "highway=footway surface=gravel"}, } if got := pavedPct(msgs); got != 40 { t.Errorf("paved = %d%%, want 40%%", got) } } func TestIntoWind(t *testing.T) { r := &Route{Generated: true, Heading: 270} if !r.IntoWind(250) || r.IntoWind(90) || (&Route{Heading: 270}).IntoWind(270) { t.Error("IntoWind wrong") } } func TestSuggestSkipsLongTrails(t *testing.T) { home := Point{51.05, 3.70} a := &Area{Routes: []Route{ {ID: 1, Name: "Streek-GR", Kind: Foot, LengthKm: 130, Ways: [][]Point{line(home, 130)}}, {ID: 3, Name: "Streek-GR loop", Kind: Foot, Loop: true, LengthKm: 130, Ways: [][]Point{line(home, 130)}}, {ID: 2, Name: "Park loop", Kind: Foot, Loop: true, LengthKm: 8, Ways: [][]Point{line(Point{51.06, 3.70}, 8)}}, }} got := Suggest(a, Foot, 10, home, 3) if len(got) != 1 || got[0].Route.ID != 2 { // loop or not, 130 km is not a 10 km workout t.Errorf("got %+v, want only the park loop", got) } }