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.
This commit is contained in:
bdeb1337 committed 2026-10-11 19:29:58 +02:00
commit d7342d6096
42 files changed
+7219

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
package weather
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// testdata holds real Open-Meteo responses for Ghent.
func fakeOpenMeteo(t *testing.T, calls *atomic.Int32) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
switch r.URL.Path {
case "/v1/forecast":
if lat := r.URL.Query().Get("latitude"); lat != "51.05" {
t.Errorf("latitude sent = %q, want it rounded to 51.05", lat)
}
http.ServeFile(w, r, "testdata/forecast.json")
case "/v1/search":
http.ServeFile(w, r, "testdata/geocode.json")
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestForecast(t *testing.T) {
var calls atomic.Int32
srv := fakeOpenMeteo(t, &calls)
c := &Client{ForecastURL: srv.URL + "/v1/forecast"}
f, err := c.Forecast(context.Background(), 51.0512345, 3.7212345)
if err != nil {
t.Fatal(err)
}
if len(f.Hours) != 24 || f.Now.Time.IsZero() || f.Sunset.Before(f.Now.Time) {
t.Errorf("forecast = now %v, %d hours, sunset %v", f.Now.Time, len(f.Hours), f.Sunset)
}
if _, off := f.Now.Time.Zone(); off != 2*3600 {
t.Errorf("zone offset = %d, want Brussels summer time", off)
}
c.Forecast(context.Background(), 51.05, 3.72)
if calls.Load() != 1 {
t.Errorf("second call within 15 min should hit the cache, made %d requests", calls.Load())
}
}
func TestGeocode(t *testing.T) {
var calls atomic.Int32
srv := fakeOpenMeteo(t, &calls)
p, err := (&Client{GeocodeURL: srv.URL + "/v1/search"}).Geocode(context.Background(), "Gent")
if err != nil || p.Name != "Ghent, Belgium" || p.Lat < 51 || p.Lat > 51.1 {
t.Errorf("geocode = %+v, %v", p, err)
}
}
func TestCompassAndIcons(t *testing.T) {
for deg, want := range map[int]string{0: "N", 44: "NE", 276: "W", 350: "N", -90: "W"} {
if got := Compass(deg); got != want {
t.Errorf("Compass(%d) = %s, want %s", deg, got, want)
}
}
if (Hour{Code: 0, IsDay: false}).Icon() != "🌙" || (Hour{Code: 63, IsDay: false}).Icon() != "🌧️" {
t.Error("night icons wrong")
}
}
// busy answers 503 like an overloaded Open-Meteo, and serves the fallbacks.
func busy(t *testing.T, openMeteoCalls *atomic.Int32) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/forecast", "/v1/search":
openMeteoCalls.Add(1)
http.Error(w, `{"reason":"The service is overloaded","error":true}`, http.StatusServiceUnavailable)
case "/metno":
if r.Header.Get("User-Agent") == "" {
http.Error(w, "403", http.StatusForbidden)
return
}
http.ServeFile(w, r, "testdata/metno.json")
case "/nominatim":
http.ServeFile(w, r, "testdata/nominatim.json")
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestForecastFallsBackToMetNo(t *testing.T) {
var calls atomic.Int32
srv := busy(t, &calls)
c := &Client{ForecastURL: srv.URL + "/v1/forecast", MetNoURL: srv.URL + "/metno",
now: func() time.Time { return time.Date(2026, 10, 11, 17, 10, 0, 0, time.UTC) }}
f, err := c.Forecast(context.Background(), 51.05, 3.72)
if err != nil {
t.Fatal(err)
}
if calls.Load() != 2 {
t.Errorf("open-meteo tried %d times, want 2 (one retry)", calls.Load())
}
if f.Source != "MET Norway" || !f.GustsEstimated || len(f.Hours) != 24 {
t.Errorf("source %q, estimated gusts %v, %d hours", f.Source, f.GustsEstimated, len(f.Hours))
}
h := f.Hours[0]
if h.PrecipProb != -1 || h.Chance() != "–" || h.Gusts <= h.Wind || h.Temp == 0 {
t.Errorf("first hour = %+v", h)
}
if !f.Hours[0].Time.Equal(time.Date(2026, 10, 11, 17, 0, 0, 0, time.UTC)) {
t.Errorf("first hour at %v, want 17:00 UTC", f.Hours[0].Time)
}
if !h.IsDay || f.Hours[1].IsDay { // sunset in Ghent is 19:01 CEST
t.Errorf("19:00 CEST should be day, 20:00 night: %v %v", h.IsDay, f.Hours[1].IsDay)
}
}
func TestStaleForecastBeatsNothing(t *testing.T) {
var calls atomic.Int32
srv := busy(t, &calls)
c := &Client{ForecastURL: srv.URL + "/v1/forecast", MetNoURL: "-"}
if _, err := c.Forecast(context.Background(), 51.05, 3.72); err == nil {
t.Fatal("expected an error with every source down and no cache")
}
c.cache = map[string]cached{"51.05,3.72": {Forecast{Source: "Open-Meteo"}, time.Now().Add(-time.Hour)}}
f, err := c.Forecast(context.Background(), 51.05, 3.72)
if err != nil || !strings.HasPrefix(f.Source, "Open-Meteo, from ") {
t.Errorf("stale forecast = %q, %v", f.Source, err)
}
}
func TestGeocodeFallsBackToNominatim(t *testing.T) {
var calls atomic.Int32
srv := busy(t, &calls)
p, err := (&Client{GeocodeURL: srv.URL + "/v1/search", NominatimURL: srv.URL + "/nominatim"}).Geocode(context.Background(), "Gent")
if err != nil || !strings.HasSuffix(p.Name, ", Belgium") || p.Lat < 51 || p.Lat > 51.1 {
t.Errorf("geocode = %+v, %v", p, err)
}
}
func TestSunTimesMatchOpenMeteo(t *testing.T) {
// Open-Meteo says 08:01 / 19:01 CEST for Ghent on 11 Oct 2026.
cest := time.FixedZone("CEST", 2*3600)
rise, set := sunTimes(time.Date(2026, 10, 11, 12, 0, 0, 0, time.UTC), 51.05, 3.72)
for _, c := range []struct {
got, want time.Time
}{{rise, time.Date(2026, 10, 11, 8, 1, 0, 0, cest)}, {set, time.Date(2026, 10, 11, 19, 1, 0, 0, cest)}} {
if d := c.got.Sub(c.want); d < -3*time.Minute || d > 3*time.Minute {
t.Errorf("got %v, want %v", c.got.In(cest), c.want)
}
}
}
func TestSymbolCode(t *testing.T) {
for sym, want := range map[string]int{
"clearsky_night": 0, "partlycloudy_day": 2, "heavyrainshowers_day": 82, "lightrainshowers_night": 80,
"rain": 63, "lightsleet": 66, "heavyrainandthunder": 95, "snowshowers_day": 85, "somethingnew": 3,
} {
if got := symbolCode(sym); got != want {
t.Errorf("symbolCode(%q) = %d, want %d", sym, got, want)
}
}
}