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.
165 lines
5.6 KiB
Go
165 lines
5.6 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|