Files
workout-suggester/internal/routes/routes_test.go
T
bdeb1337 d7342d6096 feat: workout-suggester, weather-aware workouts from a local Gemma
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.
2026-10-11 19:29:58 +02:00

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