Files
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

165 lines
5.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}