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:
42 files changed
+7219
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user