Go + Templ + HTMX app that checks the weather (Open-Meteo, MET Norway fallback), scores run/ride/walk with plain rules, and has a local open-weight model (Gemma 4 E2B via any OpenAI-compatible server) write the plan. Routes are loops from your door (BRouter) plus signposted OpenStreetMap routes (Overpass), with GPX export.
217 lines
7.2 KiB
Go
217 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|